This commit is contained in:
Alexis Bruneteau 2025-06-04 21:53:28 +02:00
parent 0f751754b1
commit c813386e18
16 changed files with 618 additions and 31 deletions

View File

@ -1,9 +1,9 @@
- hosts: web
become: yes
vars:
static_site_local_path: "{{ lookup('env', 'PWD') }}/storage/app/private/portfolios/{{ sitename }}/{{ sitehost }}"
static_site_local_path: "{{ lookup('env', 'PWD') }}/storage/app/private/portfolios/{{ sitename }}/{{ siteid }}"
kube_manifest_local_path: "{{ lookup('env', 'PWD') }}/storage/app/kube/{{ sitename }}"
target_path: /var/www/{{ sitename }}/{{ sitehost }}
target_path: /var/www/{{ sitename }}/{{ siteid }}
remote_kube_path: /tmp/kube/{{ sitename }}
tasks:

View File

@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Process;
class DeployStaticSite extends Command
{
protected $signature = 'deploy:static-site {name} {host} {id}';
protected $description = 'Generate K8s manifests, copy files with Ansible, and deploy to cluster';
public function handle()
{
$name = $this->argument('name');
$host = $this->argument('host');
$id = $this->argument('id');
$namespace = 'hosting-deploy';
$kubeDir = storage_path("app/kube/{$name}");
File::ensureDirectoryExists($kubeDir);
// 1. Generate K8s YAMLs
File::put("{$kubeDir}/deployment.yaml", $this->deployment($name,$id, $namespace));
File::put("{$kubeDir}/service.yaml", $this->service($name,$id, $namespace));
File::put("{$kubeDir}/ingress.yaml", $this->ingress($name,$host,$id, $namespace));
$this->info("✅ K8s manifests generated.");
// 2. Run Ansible to copy files and apply kube
$ansiblePlaybook = base_path("ansible/deploy_site.yml");
$process = new Process([
'ansible-playbook',
$ansiblePlaybook,
'-i', base_path('ansible/inventory/hosts.ini'),
'--extra-vars', "sitename={$name}",'--extra-vars', "siteid={$id}"
]);
$process->setTimeout(300);
$process->run(function ($type, $buffer) {
echo $buffer;
});
if ($process->isSuccessful()) {
$this->info("🚀 Site '{$name}' deployed successfully.");
} else {
$this->error("❌ Ansible deployment failed.");
}
}
private function deployment(string $name,string $id, string $namespace): string
{
return <<<YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: {$name}-{$id}-deployment
namespace: {$namespace}
labels:
app: {$name}-{$id}
spec:
replicas: 1
selector:
matchLabels:
app: {$name}-{$id}
template:
metadata:
labels:
app: {$name}-{$id}
spec:
containers:
- name: {$name}-{$id}
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: static-content
mountPath: /usr/share/nginx/html
volumes:
- name: static-content
hostPath:
path: /var/www/{$name}/{$id}
type: Directory
YAML;
}
private function service(string $name, string $id, string $namespace): string
{
return <<<YAML
apiVersion: v1
kind: Service
metadata:
name: {$name}-{$id}-service
namespace: {$namespace}
spec:
selector:
app: {$name}-{$id}
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP
YAML;
}
private function ingress(string $name,string $host,string $id, string $namespace): string
{
return <<<YAML
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {$name}-{$id}-ingress
namespace: {$namespace}
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
rules:
- host: {$host}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {$name}-{$id}-service
port:
number: 80
YAML;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers;
use App\Models\Portfolio;
use Illuminate\Http\Request;
use App\Helpers\ApiResponse;
use App\Jobs\DeployStaticSiteJob;
class PortfolioController extends Controller
{
public function index()
{
$portfolios = auth()->user()->portfolios;
return ApiResponse::success($portfolios);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'domain' => 'required|string|max:255|unique:portfolios,domain',
]);
$portfolio = auth()->user()->portfolios()->create($validated);
return ApiResponse::success($portfolio, 'Portfolio created', 201);
}
public function show(Portfolio $portfolio)
{
$this->authorizeAccess($portfolio);
return ApiResponse::success($portfolio);
}
public function update(Request $request, Portfolio $portfolio)
{
$this->authorizeAccess($portfolio);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'domain' => 'sometimes|string|max:255|unique:portfolios,domain,' . $portfolio->id,
]);
$portfolio->update($validated);
return ApiResponse::success($portfolio, 'Portfolio updated');
}
public function destroy(Portfolio $portfolio)
{
$this->authorizeAccess($portfolio);
$portfolio->delete();
return ApiResponse::success(null, 'Portfolio deleted');
}
private function authorizeAccess(Portfolio $portfolio)
{
if ($portfolio->user_id !== auth()->id()) {
abort(403, 'Unauthorized');
}
}
private function verifyActivation(Portfolio $portfolio)
{
if (!$portfolio->active)
{
abort(403, 'Portfolio unpaid');
}
}
public function upload(Request $request, Portfolio $portfolio)
{
$this->authorizeAccess($portfolio);
$this->verifyActivation($portfolio);
$request->validate([
'file' => 'required|file|max:10240', // Max 10MB
]);
$siteName = $portfolio->getAttribute('name');
$siteHost = $portfolio->getAttribute('domain');
$path = $request->file('file')->storeAs(
"portfolios/{$siteName}/{$portfolio->id}",
'index.html'
);
$portfolio->update([
'path' => $path,
]);
return ApiResponse::success(null, 'ZIP uploaded successfully');
}
public function deploy(Request $request, Portfolio $portfolio)
{
$this->authorizeAccess($portfolio);
$siteName = $portfolio->getAttribute('name');
$siteHost = $portfolio->getAttribute('domain');
DeployStaticSiteJob::dispatch($siteName, $siteHost, $portfolio->id);
return response()->json([
'message' => "Async deployment queued for '{$siteName}'."
]);
}
public function randomPortfolio()
{
return ApiResponse::success(["host" => Portfolio::inRandomOrder()->first()->domain]);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\DeployStaticSiteJob;
use Illuminate\Http\Request;
class StaticSiteController extends Controller
{
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Symfony\Component\Process\Process;
class DeployStaticSiteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public string $siteName;
public string $siteHost;
public string $siteId;
public function __construct(string $siteName, string $siteHost, string $siteId)
{
$this->siteName = $siteName;
$this->siteHost = $siteHost;
$this->siteId = $siteId;
}
public function handle(): void
{
// Run the deployment command (which already handles Ansible)
$exitCode = Artisan::call('deploy:static-site', [
'name' => $this->siteName,
'host' => $this->siteHost,
'id' => $this->siteId,
]);
if ($exitCode !== 0) {
$output = Artisan::output(); // optional: capture for logs
logger()->error("❌ deploy:static-site failed for {$this->siteName}", [
'output' => $output,
'exit_code' => $exitCode,
]);
throw new \RuntimeException("deploy:static-site failed with exit code $exitCode");
}
logger()->info("✅ deploy:static-site successful for {$this->siteName}");
}
}

26
app/Models/Portfolio.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Portfolio extends Model
{
use HasFactory;
protected $table = 'portfolios';
protected $fillable = [
'name',
'domain',
'path',
'deployed'
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -31,36 +31,7 @@ return [
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',

46
config/passport.php Normal file
View File

@ -0,0 +1,46 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Encryption Keys
|--------------------------------------------------------------------------
|
| Passport uses encryption keys while generating secure access tokens for
| your application. By default, the keys are stored as local files but
| can be set via environment variables when that is more convenient.
|
*/
'private_key' => env('PASSPORT_PRIVATE_KEY'),
'public_key' => env('PASSPORT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('portfolios', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->string('domain')->unique(); // e.g. alexis.portfolio-host.com
$table->string('path')->nullable(); // for future settings (theme, etc)
$table->boolean('deployed')->default(false);
$table->boolean('active')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('portfolios');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_auth_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->index();
$table->foreignUuid('client_id');
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_auth_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id');
$table->string('name')->nullable();
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->timestamps();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_access_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->char('access_token_id', 80)->index();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->nullableMorphs('owner');
$table->string('name');
$table->string('secret')->nullable();
$table->string('provider')->nullable();
$table->text('redirect_uris');
$table->text('grant_types');
$table->boolean('revoked');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_clients');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -21,6 +21,7 @@ spec:
env:
- name: FRONTEND_URL
value: https://portfolio-host.com
lifecycle:
postStart:
exec:

View File

@ -25,3 +25,5 @@ Route::middleware('auth:api')->group(function () {
Route::post('/portfolios/{portfolio}/upload', [PortfolioController::class, 'upload']);
Route::post('/deploy', [StaticSiteController::class, 'deploy']);
});
Route::get('/portfolio/random', [PortfolioController::class, 'randomPortfolio']);