diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 80a13cd..f08b236 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,14 +24,15 @@ "open-southeners.laravel-pint", "eamodio.gitlens", "oven.bun-vscode", - "SanderRonde.phpstan-vscode" + "SanderRonde.phpstan-vscode", + "dunstontc.vscode-docker-syntax" ], "settings": {} } }, "remoteUser": "sail", "initializeCommand": "sh .devcontainer/init.sh", - "postCreateCommand": "chown -R 1000:1000 /var/www/html 2>/dev/null || true && composer install && bun install" + "postCreateCommand": "chown -R sail:sail /var/www/html 2>/dev/null || true && composer install && bun install" // "forwardPorts": [], // "runServices": [], // "shutdownAction": "none", diff --git a/.editorconfig b/.editorconfig index d17b931..456a235 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,9 +14,6 @@ trim_trailing_whitespace = false [*.{yml,yaml,json}] indent_size = 2 -[docker-compose.yml] -indent_size = 4 - [*.{vue,js,scss,blade.php}] indent_size = 2 max_line_length=100 diff --git a/.vscode/settings.json b/.vscode/settings.json index 933ba96..9ce4cfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,20 @@ { - "editor.rulers": [ - 100 - ], - "[php]": { - "editor.defaultFormatter": "open-southeners.laravel-pint" - }, - "intelephense.codeLens.implementations.enable": true, - "intelephense.codeLens.overrides.enable": true, - "intelephense.codeLens.parent.enable": true, - "intelephense.codeLens.references.enable": true, - "intelephense.codeLens.usages.enable": true, - "intelephense.format.enable": false, - "editor.formatOnSave": true, - "laravel-pint.enable": true, - "phpstan.showTypeOnHover": true + "editor.rulers": [ + 100 + ], + "[php]": { + "editor.defaultFormatter": "open-southeners.laravel-pint" + }, + "intelephense.codeLens.implementations.enable": true, + "intelephense.codeLens.overrides.enable": true, + "intelephense.codeLens.parent.enable": true, + "intelephense.codeLens.references.enable": true, + "intelephense.codeLens.usages.enable": true, + "intelephense.format.enable": false, + "editor.formatOnSave": true, + "laravel-pint.enable": true, + "phpstan.showTypeOnHover": true, + "helper.models": false, + "intelephense.files.maxSize": 5000000, + "intelephense.telemetry.enabled": false } diff --git a/_ide_helper.php b/_ide_helper.php index 1e9fe9b..72246e6 100644 --- a/_ide_helper.php +++ b/_ide_helper.php @@ -7032,7 +7032,6 @@ * Get the returned value of a file. * * @param string $path - * @param array $data * @return mixed * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException * @static @@ -7045,7 +7044,6 @@ * Require the given file once. * * @param string $path - * @param array $data * @return mixed * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException * @static @@ -10621,42 +10619,51 @@ return $instance->setConnectionName($name); } /** - * Release a reserved job back onto the queue after (n) seconds. + * Get the number of queue jobs that are ready to process. * - * @param string $queue - * @param \Illuminate\Queue\Jobs\DatabaseJobRecord $job - * @param int $delay - * @return mixed + * @param string|null $queue + * @return int + * @static + */ public static function readyNow($queue = null) + { + /** @var \Laravel\Horizon\RedisQueue $instance */ + return $instance->readyNow($queue); + } + /** + * Migrate the delayed jobs that are ready to the regular queue. + * + * @param string $from + * @param string $to + * @return void * @static - */ public static function release($queue, $job, $delay) + */ public static function migrateExpiredJobs($from, $to) { - /** @var \Illuminate\Queue\DatabaseQueue $instance */ - return $instance->release($queue, $job, $delay); + /** @var \Laravel\Horizon\RedisQueue $instance */ + $instance->migrateExpiredJobs($from, $to); } /** * Delete a reserved job from the queue. * * @param string $queue - * @param string $id + * @param \Illuminate\Queue\Jobs\RedisJob $job * @return void - * @throws \Throwable * @static - */ public static function deleteReserved($queue, $id) + */ public static function deleteReserved($queue, $job) { - /** @var \Illuminate\Queue\DatabaseQueue $instance */ - $instance->deleteReserved($queue, $id); + /** @var \Laravel\Horizon\RedisQueue $instance */ + $instance->deleteReserved($queue, $job); } /** * Delete a reserved job from the reserved queue and release it. * * @param string $queue - * @param \Illuminate\Queue\Jobs\DatabaseJob $job + * @param \Illuminate\Queue\Jobs\RedisJob $job * @param int $delay * @return void * @static */ public static function deleteAndRelease($queue, $job, $delay) { - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + /** @var \Laravel\Horizon\RedisQueue $instance */ $instance->deleteAndRelease($queue, $job, $delay); } /** @@ -10666,8 +10673,8 @@ * @return int * @static */ public static function clear($queue) - { - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + { //Method inherited from \Illuminate\Queue\RedisQueue + /** @var \Laravel\Horizon\RedisQueue $instance */ return $instance->clear($queue); } /** @@ -10677,19 +10684,29 @@ * @return string * @static */ public static function getQueue($queue) - { - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + { //Method inherited from \Illuminate\Queue\RedisQueue + /** @var \Laravel\Horizon\RedisQueue $instance */ return $instance->getQueue($queue); } /** - * Get the underlying database instance. + * Get the connection for the queue. * - * @return \Illuminate\Database\Connection + * @return \Illuminate\Redis\Connections\Connection * @static - */ public static function getDatabase() - { - /** @var \Illuminate\Queue\DatabaseQueue $instance */ - return $instance->getDatabase(); + */ public static function getConnection() + { //Method inherited from \Illuminate\Queue\RedisQueue + /** @var \Laravel\Horizon\RedisQueue $instance */ + return $instance->getConnection(); + } + /** + * Get the underlying Redis instance. + * + * @return \Illuminate\Contracts\Redis\Factory + * @static + */ public static function getRedis() + { //Method inherited from \Illuminate\Queue\RedisQueue + /** @var \Laravel\Horizon\RedisQueue $instance */ + return $instance->getRedis(); } /** * Get the maximum number of attempts for an object-based queue handler. @@ -10699,7 +10716,7 @@ * @static */ public static function getJobTries($job) { //Method inherited from \Illuminate\Queue\Queue - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + /** @var \Laravel\Horizon\RedisQueue $instance */ return $instance->getJobTries($job); } /** @@ -10710,7 +10727,7 @@ * @static */ public static function getJobBackoff($job) { //Method inherited from \Illuminate\Queue\Queue - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + /** @var \Laravel\Horizon\RedisQueue $instance */ return $instance->getJobBackoff($job); } /** @@ -10721,7 +10738,7 @@ * @static */ public static function getJobExpiration($job) { //Method inherited from \Illuminate\Queue\Queue - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + /** @var \Laravel\Horizon\RedisQueue $instance */ return $instance->getJobExpiration($job); } /** @@ -10732,7 +10749,7 @@ * @static */ public static function createPayloadUsing($callback) { //Method inherited from \Illuminate\Queue\Queue - \Illuminate\Queue\DatabaseQueue::createPayloadUsing($callback); + \Laravel\Horizon\RedisQueue::createPayloadUsing($callback); } /** * Get the container instance being used by the connection. @@ -10741,7 +10758,7 @@ * @static */ public static function getContainer() { //Method inherited from \Illuminate\Queue\Queue - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + /** @var \Laravel\Horizon\RedisQueue $instance */ return $instance->getContainer(); } /** @@ -10752,7 +10769,7 @@ * @static */ public static function setContainer($container) { //Method inherited from \Illuminate\Queue\Queue - /** @var \Illuminate\Queue\DatabaseQueue $instance */ + /** @var \Laravel\Horizon\RedisQueue $instance */ $instance->setContainer($container); } } diff --git a/app/Console/Commands/Test.php b/app/Console/Commands/Test.php index 9ee882c..5720116 100644 --- a/app/Console/Commands/Test.php +++ b/app/Console/Commands/Test.php @@ -2,8 +2,6 @@ namespace App\Console\Commands; -use App\Facades\GameBridge; -use App\Models\GameServer; use Illuminate\Console\Command; class Test extends Command @@ -29,19 +27,6 @@ class Test extends Command */ public function handle() { - // $server = GameServer::find(6); - $bridge = GameBridge::create() - ->target(['dev', 'main1']) - // ->force(true) - ->message('status'); - $response = $bridge->send(); - dump($response); - - // $bridge = GameBridge::create() - // ->target('dev') - // ->force(true) - // ->message('ping'); - // $bridge->sendAndForget(); return 0; } } diff --git a/app/Http/Controllers/Api/GameBuildSettingsController.php b/app/Http/Controllers/Api/GameBuildSettingsController.php new file mode 100644 index 0000000..e27fc99 --- /dev/null +++ b/app/Http/Controllers/Api/GameBuildSettingsController.php @@ -0,0 +1,92 @@ +> + */ + public function index(IndexQueryRequest $request) + { + $request->validate([ + 'filters.id' => 'int', + 'filters.server' => 'string', + 'filters.branch' => 'string', + 'filters.byond_major' => 'int', + 'filters.byond_minor' => 'int', + 'filters.rustg_version' => 'string', + 'filters.rp_mode' => 'boolean', + 'filters.map_id' => 'string', + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.created_at' => new DateRange, + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.updated_at' => new DateRange, + ]); + + return GameBuildSettingResource::collection( + $this->indexQuery(GameBuildSetting::class) + ); + } + + /** + * Add + * + * Add a new game build setting + */ + public function store(GameBuildSettingCreateRequest $request) + { + return $this->addSetting($request); + } + + /** + * Update + * + * Update an existing game build setting + */ + public function update(GameBuildSettingUpdateRequest $request, GameBuildSetting $setting) + { + return $this->updateSetting($request, $setting); + } + + /** + * Delete + * + * Delete an existing game build setting + */ + public function destroy(GameBuildSetting $setting) + { + $setting->delete(); + + return ['message' => 'Setting removed']; + } +} diff --git a/app/Http/Controllers/Api/GameBuildTestMergesController.php b/app/Http/Controllers/Api/GameBuildTestMergesController.php new file mode 100644 index 0000000..5960773 --- /dev/null +++ b/app/Http/Controllers/Api/GameBuildTestMergesController.php @@ -0,0 +1,94 @@ +> + */ + public function index(IndexQueryRequest $request) + { + $request->validate([ + 'filters.id' => 'int', + 'filters.pr' => 'int', + 'filters.server' => 'string', + 'filters.added_by' => 'string', + 'filters.updated_by' => 'string', + 'filters.commit' => 'string', + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.created_at' => new DateRange, + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.updated_at' => new DateRange, + ]); + + return GameBuildTestMergeResource::collection( + $this->indexQuery(GameBuildTestMerge::class) + ); + } + + /** + * Add + * + * Add a new game build test merge + */ + public function store(GameBuildTestMergeCreateRequest $request) + { + return $this->addTestMerge($request); + } + + /** + * Update + * + * Update an existing game build test merge + */ + public function update(GameBuildTestMergeUpdateRequest $request, GameBuildTestMerge $testMerge) + { + try { + return $this->updateTestMerge($request, $testMerge); + } catch (\Exception $e) { + return response()->json(['message' => $e->getMessage()], 400); + } + } + + /** + * Delete + * + * Delete an existing game build test merge + */ + public function destroy(GameBuildTestMerge $testMerge) + { + $testMerge->delete(); + + return ['message' => 'Test merge removed']; + } +} diff --git a/app/Http/Controllers/Api/GameBuildsController.php b/app/Http/Controllers/Api/GameBuildsController.php new file mode 100644 index 0000000..4162f86 --- /dev/null +++ b/app/Http/Controllers/Api/GameBuildsController.php @@ -0,0 +1,144 @@ +> + */ + public function index(IndexQueryRequest $request) + { + $request->validate([ + 'filters.id' => 'int', + /** @example main1 */ + 'filters.server' => 'string', + 'filters.started_by' => 'string', + 'filters.branch' => 'string', + 'filters.commit' => 'string', + 'filters.map_id' => 'string', + 'filters.failed' => 'boolean', + 'filters.cancelled' => 'boolean', + 'filters.map_switch' => 'boolean', + 'filters.cancelled_by' => 'string', + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.created_at' => new DateRange, + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.updated_at' => new DateRange, + /** + * A date or date range + * + * @example 2023/01/30 12:00:00 - 2023/02/01 12:00:00 + */ + 'filters.ended_at' => new DateRange, + ]); + + return GameBuildResource::collection( + $this->indexQuery(GameBuild::class) + ); + } + + /** + * Status + * + * Get the current status of game builds in process or queued + */ + public function status() + { + $res = ['current' => [], 'queued' => []]; + /** @var \Illuminate\Redis\Connections\PhpRedisConnection */ + $redis = Queue::getRedis(); + $reservedJobs = $redis->zrange('queues:default:reserved', 0, -1); + foreach ($reservedJobs as $job) { + $job = json_decode($job); + if ($job->data->commandName === GameBuildJob::class) { + $runningJob = collect(); + foreach ($job->tags as $tag) { + if (str_starts_with($tag, GameAdmin::class)) { + [$type, $id] = explode(':', $tag); + $runningJob->put('admin', GameAdmin::firstWhere('id', $id)); + } elseif (str_starts_with($tag, GameServer::class)) { + [$type, $id] = explode(':', $tag); + $runningJob->put('server', GameServer::firstWhere('id', $id)); + } + } + + if ($runningJob->has('server')) { + $queuedJob = Cache::get("GameBuild-{$runningJob->get('server')->server_id}-queued"); + if ($queuedJob) { + $res['queued'][] = new GameBuildStatusQueuedResource([ + 'admin' => GameAdmin::firstWhere('id', $queuedJob['admin']), + 'server' => $runningJob->get('server'), + 'type' => $queuedJob['type'], + ]); + } + } + + $res['current'][] = new GameBuildStatusCurrentResource($runningJob); + } + } + + return new GameBuildStatusResource($res); + } + + /** + * Build + * + * Run a game build + */ + public function build(GameBuildCreateRequest $request) + { + $this->addBuild($request); + + return ['message' => 'Success']; + } + + /** + * Cancel + * + * Cancel a build + */ + public function cancel(GameBuildCancelRequest $request) + { + $this->cancelBuild($request); + + return ['message' => 'Success']; + } +} diff --git a/app/Http/Requests/GameBuildCancelRequest.php b/app/Http/Requests/GameBuildCancelRequest.php new file mode 100644 index 0000000..a072ede --- /dev/null +++ b/app/Http/Requests/GameBuildCancelRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules() + { + return [ + 'game_admin_ckey' => 'required|string', + 'server_id' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/GameBuildCreateRequest.php b/app/Http/Requests/GameBuildCreateRequest.php new file mode 100644 index 0000000..1b99d10 --- /dev/null +++ b/app/Http/Requests/GameBuildCreateRequest.php @@ -0,0 +1,32 @@ + + */ + public function rules() + { + return [ + 'game_admin_ckey' => 'required|string', + 'server_id' => 'required|string', + 'map' => 'nullable|string|exists:maps,map_id', + ]; + } +} diff --git a/app/Http/Requests/GameBuildSettingCreateRequest.php b/app/Http/Requests/GameBuildSettingCreateRequest.php new file mode 100644 index 0000000..7501003 --- /dev/null +++ b/app/Http/Requests/GameBuildSettingCreateRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules() + { + return [ + 'server_id' => 'required|string|exists:game_servers,server_id|unique:game_build_settings,server_id', + 'branch' => 'required|string', + 'byond_major' => 'required|integer', + 'byond_minor' => 'required|integer', + 'rustg_version' => 'required|string', + 'rp_mode' => 'nullable|boolean', + 'map_id' => 'nullable|string|exists:maps,map_id', + ]; + } +} diff --git a/app/Http/Requests/GameBuildSettingUpdateRequest.php b/app/Http/Requests/GameBuildSettingUpdateRequest.php new file mode 100644 index 0000000..01786c1 --- /dev/null +++ b/app/Http/Requests/GameBuildSettingUpdateRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules() + { + return [ + 'branch' => 'nullable|string', + 'byond_major' => 'nullable|integer', + 'byond_minor' => 'nullable|integer', + 'rustg_version' => 'nullable|string', + 'rp_mode' => 'nullable|boolean', + 'map_id' => 'nullable|string|exists:maps,map_id', + ]; + } +} diff --git a/app/Http/Requests/GameBuildTestMergeCreateRequest.php b/app/Http/Requests/GameBuildTestMergeCreateRequest.php new file mode 100644 index 0000000..1231962 --- /dev/null +++ b/app/Http/Requests/GameBuildTestMergeCreateRequest.php @@ -0,0 +1,44 @@ + + */ + public function rules() + { + $serverId = $this->input('server_id'); + $prId = $this->input('pr_id'); + + return [ + 'game_admin_id' => 'required_without:game_admin_ckey|exists:game_admins,id', + 'game_admin_ckey' => 'required_without:game_admin_id|exists:game_admins,ckey', + 'pr_id' => [ + 'required', + 'integer', + Rule::unique('game_build_test_merges')->where(function ($q) use ($serverId, $prId) { + return $q->where('server_id', $serverId)->where('pr_id', $prId); + }), + ], + 'server_id' => 'required|string|exists:game_servers,server_id', + 'commit' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/GameBuildTestMergeUpdateRequest.php b/app/Http/Requests/GameBuildTestMergeUpdateRequest.php new file mode 100644 index 0000000..26e9d2b --- /dev/null +++ b/app/Http/Requests/GameBuildTestMergeUpdateRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules() + { + return [ + 'game_admin_id' => 'required_without:game_admin_ckey|exists:game_admins,id', + 'game_admin_ckey' => 'required_without:game_admin_id|exists:game_admins,ckey', + 'pr_id' => 'nullable|integer', + 'server_id' => 'nullable|string|exists:game_servers,server_id', + 'commit' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Resources/GameBuildResource.php b/app/Http/Resources/GameBuildResource.php new file mode 100644 index 0000000..7a0b66e --- /dev/null +++ b/app/Http/Resources/GameBuildResource.php @@ -0,0 +1,35 @@ + $this->id, + 'server_id' => $this->server_id, + 'started_by' => $this->startedBy, + 'branch' => $this->branch, + 'commit' => $this->commit, + 'map_id' => $this->map_id, + 'failed' => $this->failed, + 'cancelled' => $this->cancelled, + 'map_switch' => $this->map_switch, + 'cancelled_by' => $this->cancelledBy, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'ended_at' => $this->ended_at, + ]; + } +} diff --git a/app/Http/Resources/GameBuildSettingResource.php b/app/Http/Resources/GameBuildSettingResource.php new file mode 100644 index 0000000..32c8623 --- /dev/null +++ b/app/Http/Resources/GameBuildSettingResource.php @@ -0,0 +1,32 @@ + $this->id, + 'server_id' => $this->server_id, + 'branch' => $this->branch, + 'byond_major' => $this->byond_major, + 'byond_minor' => $this->byond_minor, + 'rustg_version' => $this->rustg_version, + 'rp_mode' => $this->rp_mode, + 'map_id' => $this->map_id, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/GameBuildStatusCurrentResource.php b/app/Http/Resources/GameBuildStatusCurrentResource.php new file mode 100644 index 0000000..258a088 --- /dev/null +++ b/app/Http/Resources/GameBuildStatusCurrentResource.php @@ -0,0 +1,20 @@ + $this['admin'], + /** @var GameServer */ + 'server' => $this['server'], + ]; + } +} diff --git a/app/Http/Resources/GameBuildStatusQueuedResource.php b/app/Http/Resources/GameBuildStatusQueuedResource.php new file mode 100644 index 0000000..2cc01cb --- /dev/null +++ b/app/Http/Resources/GameBuildStatusQueuedResource.php @@ -0,0 +1,21 @@ + $this['admin'], + /** @var GameServer */ + 'server' => $this['server'], + 'type' => $this['type'], + ]; + } +} diff --git a/app/Http/Resources/GameBuildStatusResource.php b/app/Http/Resources/GameBuildStatusResource.php new file mode 100644 index 0000000..68ab487 --- /dev/null +++ b/app/Http/Resources/GameBuildStatusResource.php @@ -0,0 +1,24 @@ + $this['current'], + /** @var GameBuildStatusQueuedResource Game builds in queue */ + 'queued' => $this['queued'], + ]; + } +} diff --git a/app/Http/Resources/GameBuildTestMergeResource.php b/app/Http/Resources/GameBuildTestMergeResource.php new file mode 100644 index 0000000..5610f82 --- /dev/null +++ b/app/Http/Resources/GameBuildTestMergeResource.php @@ -0,0 +1,30 @@ + $this->id, + 'pr_id' => $this->pr_id, + 'server_id' => $this->server_id, + 'added_by' => $this->addedBy, + 'updated_by' => $this->updatedBy, + 'commit' => $this->commit, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Jobs/GameBuild.php b/app/Jobs/GameBuild.php new file mode 100644 index 0000000..b63668e --- /dev/null +++ b/app/Jobs/GameBuild.php @@ -0,0 +1,101 @@ +admin = $admin; + $this->server = $server; + $this->mapSwitch = $mapSwitch; + $this->queuedCacheKey = "GameBuild-{$this->server->server_id}-queued"; + + $isBuilding = $this->isBuilding($this->server->server_id); + if ($isBuilding && $this->mapSwitch) { + Cache::set($this->queuedCacheKey, [ + 'type' => 'switch', + 'admin' => $this->admin->id, + 'jobLockOwner' => $isBuilding, + ], 300); + } + } + + public static function isBuilding(string $serverId) + { + /** @var \Illuminate\Redis\Connections\PhpRedisConnection $conn */ + // @phpstan-ignore-next-line staticMethod.notFound + $conn = Cache::lockConnection(); + $prefix = Cache::getPrefix(); + $key = "{$prefix}laravel_unique_job:".self::class.":$serverId"; + + return $conn->get($key); + } + + public function uniqueId(): string + { + return $this->server->server_id; + } + + public function runQueuedBuild() + { + $queuedBuild = Cache::get($this->queuedCacheKey); + if ($queuedBuild) { + Cache::forget($this->queuedCacheKey); + $lock = Cache::lock( + 'laravel_unique_job:'.self::class.':'.$this->uniqueId(), + owner: $queuedBuild['jobLockOwner'] + ); + $lock->release(); + GameBuild::dispatch($this->admin, $this->server, true); + } + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $build = new Build($this->server, $this->admin, $this->mapSwitch); + $build->start(); + $this->runQueuedBuild(); + } + + public function failed(?\Throwable $exception): void + { + $this->runQueuedBuild(); + } +} diff --git a/app/Jobs/Test.php b/app/Jobs/Test.php deleted file mode 100644 index 9721ec3..0000000 --- a/app/Jobs/Test.php +++ /dev/null @@ -1,40 +0,0 @@ -first(); - $admin = GameAdmin::where('ckey', 'wirewraith')->first(); - $build = new Build($server, $admin); - $build->build(); - } -} diff --git a/app/Libraries/DiscordBot.php b/app/Libraries/DiscordBot.php index 5e1cc34..19d1174 100644 --- a/app/Libraries/DiscordBot.php +++ b/app/Libraries/DiscordBot.php @@ -21,7 +21,7 @@ public static function getConfig() * * @throws \Illuminate\Http\Client\RequestException */ - public static function export(string $route, string $method = 'GET', array $data) + public static function export(string $route, string $method, array $data) { $config = DiscordBot::getConfig(); if (! $config['url']) { @@ -30,10 +30,10 @@ public static function export(string $route, string $method = 'GET', array $data $url = $config['url']; $data['api_key'] = $config['key']; - if (!$data['server_name']) { + if (empty($data['server_name'])) { $data['server_name'] = 'Goonhub'; } - if (!$data['server']) { + if (empty($data['server'])) { $data['server'] = 'goonhub'; } diff --git a/app/Libraries/GameBuilder/Build.php b/app/Libraries/GameBuilder/Build.php index 507282d..c5b3f71 100644 --- a/app/Libraries/GameBuilder/Build.php +++ b/app/Libraries/GameBuilder/Build.php @@ -2,59 +2,92 @@ namespace App\Libraries\GameBuilder; +use App\Facades\GameBridge; use App\Libraries\DiscordBot; -use App\Libraries\GameBridge; use App\Models\GameAdmin; use App\Models\GameBuild; +use App\Models\GameBuildSecret; use App\Models\GameBuildSetting; use App\Models\GameBuildTestMerge; use App\Models\GameServer; +use Cache; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Process as FacadesProcess; +use Str; use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\Process; use ZipArchive; class Build { private GameServer $server; + private GameAdmin $admin; private Repo $repo; + private GameBuild $model; + private GameBuildSetting $settings; private $mapSwitch = false; private $rootDir; + private $tmpDir; + private $rootByondDir; + private $rootRustgDir; + private $byondDir; + private $serverDir; + private $buildDir; + private $buildCdnDir; + private $deployDir; + private $rustgDir; private $deployTargetRoot = '/remote-game'; + private $deployTarget; + private $cdnTargetRoot = '/cdn'; + private $cdnTarget; + private $cdnNodeVersion = '18.20.5'; + private $testMergeBranch; + private $testMergeSuccesses = []; + private $testMergeConflicts = []; private $error; + private $cancelled = false; + + private $procCacheKey; + + private $cancelCacheKey; + public function __construct(GameServer $server, GameAdmin $admin, bool $mapSwitch = false) { $this->server = $server; $this->admin = $admin; - $this->settings = GameBuildSetting::where('server_id', $server->server_id)->first(); + $this->settings = GameBuildSetting::with(['map']) + ->where('server_id', $server->server_id) + ->first(); - $this->mapSwitch = $mapSwitch && $this->settings->map ? true : false; + $this->mapSwitch = $mapSwitch && $this->settings->map_id ? true : false; $this->rootDir = storage_path('app/game-builder'); $this->tmpDir = "{$this->rootDir}/tmp"; @@ -71,15 +104,20 @@ public function __construct(GameServer $server, GameAdmin $admin, bool $mapSwitc $this->deployTarget = "{$this->deployTargetRoot}/servers/{$server->server_id}"; $this->cdnTarget = "{$this->cdnTargetRoot}/{$server->server_id}"; - $this->repo = new Repo($this->serverDir); + $this->procCacheKey = "GameBuild-{$this->server->server_id}-proc"; + Cache::forget($this->procCacheKey); + $this->cancelCacheKey = "GameBuild-{$this->server->server_id}-cancel"; + Cache::forget($this->cancelCacheKey); - if (!File::exists($this->rootDir)) { + $this->repo = new Repo($this, $this->serverDir); + + if (File::missing($this->rootDir)) { File::makeDirectory($this->rootDir); } $this->log("Created build object for {$server->server_id}"); if ($this->mapSwitch) { - $this->log("Map switch build detected: {$this->settings->map}"); + $this->log("Map switch build detected: {$this->settings->map->name}"); } } @@ -88,26 +126,55 @@ public function log($msg) echo '['.date('Y-m-d H:i:s').'] '.$msg.PHP_EOL; } - public function createModel() + public function runProcess(Process $process) + { + $process->start(); + Cache::set($this->procCacheKey, $process->getPid()); + $process->wait(); + Cache::forget($this->procCacheKey); + } + + public function checkCancelled() + { + if (Cache::has($this->cancelCacheKey)) { + Cache::forget($this->cancelCacheKey); + throw new CancelledException; + } + } + + public static function cancel(string $serverId, int $adminId) + { + Cache::set("GameBuild-$serverId-cancel", $adminId); + $pid = Cache::get("GameBuild-$serverId-proc"); + if ($pid) { + FacadesProcess::run("kill -9 $pid"); + } + } + + private function createModel() { - $this->log("Creating model"); - $gameBuild = new GameBuild(); + $this->checkCancelled(); + $this->log('Creating model'); + $gameBuild = new GameBuild; $gameBuild->server_id = $this->server->server_id; $gameBuild->started_by = $this->admin->id; $gameBuild->branch = $this->settings->branch; - $gameBuild->map = $this->settings->map; + $gameBuild->map_switch = $this->mapSwitch; + $gameBuild->map_id = $this->settings->map_id; $gameBuild->save(); $this->model = $gameBuild; } - public function mergeTestMerges() + private function mergeTestMerges() { + $this->checkCancelled(); $testMerges = GameBuildTestMerge::where('server_id', $this->server->server_id) ->get(); if ($testMerges->isEmpty()) { - $this->log("[Test Merges] None found"); + $this->log('[Test Merges] None found'); + return; } @@ -122,8 +189,9 @@ public function mergeTestMerges() } } - public function mergeTestMerge(GameBuildTestMerge $testMerge) + private function mergeTestMerge(GameBuildTestMerge $testMerge) { + $this->checkCancelled(); $this->log("[Test Merges] Merging PR #{$testMerge->pr_id}"); $prBranch = "pr-{$testMerge->pr_id}"; @@ -135,37 +203,39 @@ public function mergeTestMerge(GameBuildTestMerge $testMerge) // Move the new PR branch to a specific commit if specified $this->repo->resetBranchToCommit($testMerge->commit); } else { - // If no commit was specified, save whatever the latest HEAD commit is - // This is so we don't always update to the latest on future merges, which introduces security risks + // If no commit was specified, save whatever the latest HEAD commit is + // This is so we don't always update to the latest on future merges, which introduces security risks $latestCommitHash = $this->repo->getHash(); $testMerge->commit = $latestCommitHash; $testMerge->save(); } - // Move back to our primary test merge branch + // Move back to our primary test merge branch $this->repo->checkout($this->testMergeBranch); - // Attempt to merge PR in, with handling to skip it if there are conflicts + // Attempt to merge PR in, with handling to skip it if there are conflicts try { $this->repo->merge($prBranch); - } catch (ProcessFailedException $e) { - dump($e->getMessage()); - $this->log("[Test Merges] Failed to merge due to conflicts"); + } catch (ProcessFailedException) { + $this->log('[Test Merges] Failed to merge due to conflicts'); $this->testMergeConflicts[] = [ 'prId' => $testMerge->pr_id, - 'files' => $this->repo->getConflictedFiles() + 'files' => $this->repo->getConflictedFiles(), ]; $this->repo->abortMerge(); + return false; } $this->repo->commit("Testmerge $prBranch"); + return true; } - public function generateBuildDefines() + private function generateBuildDefines() { - $this->log("Generating build defines"); + $this->checkCancelled(); + $this->log('Generating build defines'); $localHash = $this->repo->getHash(); $localAuthor = $this->repo->getAuthor($localHash); $originHash = $this->repo->getHash($this->settings->branch); @@ -190,16 +260,16 @@ public function generateBuildDefines() "#define BUILD_TIME_MINUTE {$now->minute}", "#define BUILD_TIME_SECOND {$now->second}", "#define BUILD_TIME_UNIX {$now->unix()}", - "#define PRELOAD_RSC_URL \"https://cdn-{$this->server->server_id}.goonhub.com/rsc.zip\"" + "#define PRELOAD_RSC_URL \"https://cdn-{$this->server->server_id}.goonhub.com/rsc.zip\"", ]; - if ($this->settings->map) { - $defines[] = "#define MAP_OVERRIDE_{$this->settings->map}"; + if ($this->settings->map_id) { + $defines[] = "#define MAP_OVERRIDE_{$this->settings->map_id}"; } if ($this->settings->rp_mode) { $defines[] = '#define RP_MODE'; } - if (!empty($this->testMergeSuccesses)) { + if (! empty($this->testMergeSuccesses)) { $mergedPrIds = implode(',', $this->testMergeSuccesses); $defines[] = "#define TESTMERGE_PRS list($mergedPrIds)"; @@ -211,9 +281,26 @@ public function generateBuildDefines() return implode("\n", $defines); } - public function prepareBuildDir() + private function generateSecrets() + { + $this->checkCancelled(); + $secrets = GameBuildSecret::all()->map(function ($secret) { + $key = Str::upper($secret->key); + + return "$key {$secret->value}"; + })->toArray(); + + $secrets[] = 'GOONHUB_URL '.config('app.url'); + $secrets[] = 'GOONHUB_API_ENDPOINT '.config('app.api_url'); + $secrets[] = 'GOONHUB_EVENTS_PASSWORD '.config('database.redis.events.password'); + + return implode("\n", $secrets); + } + + private function prepareBuildDir() { - $this->log("Preparing build directory"); + $this->checkCancelled(); + $this->log('Preparing build directory'); // Ensure build dir exists and is empty if (File::exists($this->buildDir)) { File::deleteDirectory($this->buildDir, preserve: true); @@ -221,32 +308,35 @@ public function prepareBuildDir() File::makeDirectory($this->buildDir, recursive: true); } - // Done old-school so I can glob, which ignores dot files (and we don't want .git here) - shell_exec("cp -r {$this->repo->repoDir}/* {$this->buildDir}"); + // Copy repo dir contents to build, without git history + $process = Process::fromShellCommandline("rsync -a --exclude=.git {$this->repo->repoDir}/ {$this->buildDir}"); + $this->runProcess($process); // Defines needed to compile the game correctly File::put("{$this->buildDir}/_std/__build.dm", $this->generateBuildDefines()); // Merge various secret things into the main repo - shell_exec("cp -r {$this->buildDir}/+secret/config/* {$this->buildDir}/config/"); + File::copyDirectory("{$this->buildDir}/+secret/config", "{$this->buildDir}/config"); // Add secret tokens to config - File::append("{$this->buildDir}/config/config.txt", File::get("{$this->rootDir}/keys.txt")); + File::append("{$this->buildDir}/config/config.txt", $this->generateSecrets()); } - public function updateByond() + private function updateByond() { + $this->checkCancelled(); $version = "{$this->settings->byond_major}.{$this->settings->byond_minor}"; if (File::exists($this->byondDir)) { // Byond version already downloaded $this->log("[Byond] Already downloaded $version"); + return; } - $this->log("[Byond] Updating"); + $this->log('[Byond] Updating'); - if (!File::exists($this->rootByondDir)) { + if (! File::exists($this->rootByondDir)) { File::makeDirectory($this->rootByondDir); } @@ -256,7 +346,7 @@ public function updateByond() Http::sink("$workDir/byond.zip") ->get("https://www.byond.com/download/build/{$this->settings->byond_major}/{$version}_byond_linux.zip"); - $zip = new ZipArchive(); + $zip = new ZipArchive; $zip->open("$workDir/byond.zip"); $zip->extractTo($workDir); $zip->close(); @@ -268,9 +358,10 @@ public function updateByond() $this->log("[Byond] Downloaded $version"); } - public function compile() + private function compile() { - $this->log("[Compile] Compiling"); + $this->checkCancelled(); + $this->log('[Compile] Compiling'); // Debug: cause a compile error // File::append("{$this->buildDir}/code/world/world.dm", "\nthisProcDoesNotExist()"); @@ -280,18 +371,19 @@ public function compile() cwd: $this->buildDir, env: [ 'PATH' => "{$this->byondDir}/bin:".getenv('PATH'), - 'LD_LIBRARY_PATH' => "{$this->byondDir}/bin" + 'LD_LIBRARY_PATH' => "{$this->byondDir}/bin", ], timeout: 300 // 5 minutes ); - $process->mustRun(); + $this->runProcess($process); - $this->log("[Compile] Success"); + $this->log('[Compile] Success'); } - public function prepareDeployDir() + private function prepareDeployDir() { - $this->log("Preparing deploy directory"); + $this->checkCancelled(); + $this->log('Preparing deploy directory'); // Ensure deploy dir exists and is "empty" if (File::exists($this->deployDir)) { File::deleteDirectory($this->deployDir, preserve: true); @@ -302,11 +394,12 @@ public function prepareDeployDir() File::makeDirectory("{$this->deployDir}/+secret", recursive: true); } - public function prepareCompiledAssetsForDeploy() + private function prepareCompiledAssetsForDeploy() { - $this->log("Preparing compiled assets for deployment"); - $files = ['goonstation.dmb', 'goonstation.rsc']; - $dirs = ['assets', 'config', 'strings', 'sound']; + $this->checkCancelled(); + $this->log('Preparing compiled assets for deployment'); + $files = ['goonstation.dmb', 'goonstation.rsc', 'buildByond.conf']; + $dirs = ['assets', 'config', 'strings', 'sound', 'tools']; $secretDirs = ['assets', 'strings']; foreach ($files as $file) { @@ -326,25 +419,27 @@ public function prepareCompiledAssetsForDeploy() ->get("https://api.github.com/repos/goonstation/goonstation/pulls/$prId"); } - $zip = new ZipArchive(); + $zip = new ZipArchive; $zip->open("{$this->deployDir}/rsc.zip", ZipArchive::CREATE); $zip->addFile("{$this->deployDir}/goonstation.rsc", 'goonstation.rsc'); $zip->close(); } - public function buildRustg() + private function buildRustg() { + $this->checkCancelled(); $this->rustgDir = "{$this->rootRustgDir}/{$this->settings->rustg_version}"; if (File::exists($this->rustgDir)) { // Rust-g version already built $this->log("[Rust-G] Already built {$this->settings->rustg_version}"); + return; } - $this->log("[Rust-G] Updating"); + $this->log('[Rust-G] Updating'); - if (!File::exists($this->rootRustgDir)) { + if (! File::exists($this->rootRustgDir)) { File::makeDirectory($this->rootRustgDir); } @@ -353,23 +448,23 @@ public function buildRustg() $process = new Process([ 'git', 'clone', '--depth', 1, '--branch', $this->settings->rustg_version, - 'https://github.com/goonstation/rust-g', $workDir + 'https://github.com/goonstation/rust-g', $workDir, ]); - $process->mustRun(); + $this->runProcess($process); $cargoBin = getenv('HOME').'/.cargo/bin'; $process = new Process( [ - "$cargoBin/cargo", 'build', '--release', '--target', 'i686-unknown-linux-gnu', '--features', 'all' + "$cargoBin/cargo", 'build', '--release', '--target', 'i686-unknown-linux-gnu', '--features', 'all', ], cwd: $workDir, env: [ 'RUSTFLAGS' => '-C target-cpu=native', - 'PKG_CONFIG_ALLOW_CROSS' => 1 + 'PKG_CONFIG_ALLOW_CROSS' => 1, ], timeout: 300 ); - $process->mustRun(); + $this->runProcess($process); File::makeDirectory($this->rustgDir); File::copy("$workDir/target/i686-unknown-linux-gnu/release/librust_g.so", "{$this->rustgDir}/librust_g.so"); @@ -379,8 +474,9 @@ public function buildRustg() $this->log("[Rust-G] Updated to {$this->settings->rustg_version}"); } - public function buildCdn() + private function buildCdn() { + $this->checkCancelled(); if (File::exists($this->buildCdnDir)) { $process = new Process([ 'diff', '-qr', @@ -389,66 +485,79 @@ public function buildCdn() '-x', 'revision', '-x', 'package-lock.json', 'browserassets', - $this->buildCdnDir + $this->buildCdnDir, ], $this->buildDir); $process->run(); - if (!$process->getOutput()) { + if (! $process->getOutput()) { // CDN files haven't changed, avoid rebuilding - $this->log("[CDN] No changes"); + $this->log('[CDN] No changes'); + return; } } else { File::makeDirectory($this->buildCdnDir); } - $this->log("[CDN] Building"); + $this->log('[CDN] Building'); + + $nvm = getenv('NVM_DIR'); + $nvmUse = fn ($cmd) => sprintf("bash -c '. %s/nvm.sh ; %s ;'", $nvm, $cmd); + + // Install target Node version if missing + if (File::missing("$nvm/versions/node/v{$this->cdnNodeVersion}")) { + $this->log('[CDN] Missing target Node version, installing'); + $process = Process::fromShellCommandline($nvmUse("nvm install {$this->cdnNodeVersion}")); + $this->runProcess($process); + } // Clean out old CDN build dir, but keep node modules so we don't have to waste time reinstalling them all File::moveDirectory("{$this->buildCdnDir}/node_modules", "{$this->serverDir}/node_modules"); File::deleteDirectory($this->buildCdnDir, preserve: true); File::moveDirectory("{$this->serverDir}/node_modules", "{$this->buildCdnDir}/node_modules"); - shell_exec("mv {$this->buildDir}/browserassets/* {$this->buildCdnDir}"); + $process = Process::fromShellCommandline("mv {$this->buildDir}/browserassets/* {$this->buildCdnDir}"); + $this->runProcess($process); File::put("{$this->buildCdnDir}/revision", $this->repo->getHash()); - $process = new Process([ - 'npm', 'install', '--no-progress' - ], $this->buildCdnDir, timeout: 300); - $process->mustRun(); - - $process = new Process([ - 'npm', 'run', 'build', '--', '--servertype', $this->server->server_id - ], $this->buildCdnDir, timeout: 300); - $process->mustRun(); + $process = Process::fromShellCommandline( + $nvmUse("nvm exec {$this->cdnNodeVersion} npm install --no-progress"), + $this->buildCdnDir, timeout: 300 + ); + $this->runProcess($process); + $process = Process::fromShellCommandline( + $nvmUse("nvm exec {$this->cdnNodeVersion} npm run build -- --servertype {$this->server->server_id}"), + $this->buildCdnDir, timeout: 300 + ); + $this->runProcess($process); File::moveDirectory("{$this->buildCdnDir}/build", "{$this->deployDir}/cdn"); - - $this->log("[CDN] Built"); + $this->log('[CDN] Built'); } - public function deploy() + private function deploy() { - $this->log("Deploying"); + $this->checkCancelled(); + $this->log('Deploying'); // Byond $process = Process::fromShellCommandline("rsync -ar --ignore-existing {$this->rootByondDir}/* {$this->deployTargetRoot}/byond/"); - $process->mustRun(); + $this->runProcess($process); // Rust-G $process = Process::fromShellCommandline("rsync -ar --ignore-existing {$this->rootRustgDir}/* {$this->deployTargetRoot}/rust-g/"); - $process->mustRun(); + $this->runProcess($process); // CDN - if (!File::exists($this->cdnTarget)) { + if (! File::exists($this->cdnTarget)) { File::makeDirectory($this->cdnTarget); } $process = Process::fromShellCommandline("mv {$this->deployDir}/rsc.zip {$this->cdnTarget}/"); - $process->mustRun(); + $this->runProcess($process); if (File::exists("{$this->deployDir}/cdn")) { $process = Process::fromShellCommandline("rsync -rl {$this->deployDir}/cdn/* {$this->cdnTarget}/ && rm -r {$this->deployDir}/cdn"); - $process->mustRun(); + $this->runProcess($process); } // Stamp runtime tool versions so the game startup script can pick the right stuff @@ -456,23 +565,26 @@ public function deploy() "BYOND_DIR={$this->settings->byond_major}.{$this->settings->byond_minor}", "RUSTG_DIR={$this->settings->rustg_version}", ]; - File::put("{$this->deployDir}/build.env", implode("\n", $buildEnv)); + File::put("{$this->deployDir}/.env.build", implode("\n", $buildEnv)); // Game $gameUpdateDir = "{$this->deployTarget}/game/update"; - if (!File::exists($gameUpdateDir)) { + if (! File::exists($gameUpdateDir)) { File::makeDirectory($gameUpdateDir, recursive: true); } $process = Process::fromShellCommandline("rm -r $gameUpdateDir/* >/dev/null 2>&1"); $process->run(); $process = Process::fromShellCommandline("mv {$this->deployDir}/* $gameUpdateDir/"); - $process->mustRun(); + $this->runProcess($process); + $process = Process::fromShellCommandline("mv {$this->deployDir}/.* $gameUpdateDir/"); + $this->runProcess($process); } private function buildFull() { + $this->checkCancelled(); $this->repo->fetch(); - $this->repo->update(); + $this->repo->reset(); $this->model->commit = $this->repo->getHash(); $this->model->save(); @@ -491,6 +603,7 @@ private function buildFull() private function buildMapSwitch() { + $this->checkCancelled(); $this->model->commit = $this->repo->getHash(); $this->model->save(); @@ -502,7 +615,7 @@ private function buildMapSwitch() $this->prepareCompiledAssetsForDeploy(); } - public function build() + public function start() { $this->log('Starting build'); @@ -511,7 +624,7 @@ public function build() $this->log('Resetting repo'); $this->repo->init(); - $this->repo->checkout($this->settings->branch); + $this->repo->checkoutRemote($this->settings->branch); if ($this->mapSwitch) { $this->buildMapSwitch(); @@ -526,30 +639,45 @@ public function build() } } catch (ProcessFailedException $e) { - dump($e->getMessage()); $process = $e->getProcess(); + $cmd = $process->getCommandLine(); $message = $process->getErrorOutput(); - if (!$message) $message = $process->getOutput(); - $this->log("[PROCESS EXCEPTION] $message"); + if (! $message) { + $message = $process->getOutput(); + } + $this->log("[PROCESS EXCEPTION] $cmd\n $message"); $this->error = $message ? $message : true; - } catch (\Exception $e) { + } catch (ProcessSignaledException|CancelledException) { + $cancelledBy = Cache::get($this->cancelCacheKey); + Cache::forget($this->cancelCacheKey); + + $this->cancelled = true; + $this->model->cancelled = true; + $this->model->cancelled_by = $cancelledBy; + $this->log('[CANCELLED]'); + + } catch (\Throwable $e) { + $test = $e::class; $message = $e->getMessage(); - $this->log("[EXCEPTION] $message"); + $this->log("[EXCEPTION] $test $message"); $this->error = $message ? $message : true; } $this->model->ended_at = now(); - $this->model->failed = !!$this->error; + $this->model->failed = (bool) $this->error; $this->model->save(); - // $this->notifyDiscordBot(); + $this->notifyDiscordBot(); $this->log('Finished build'); } - public function notifyDiscordBot() + private function notifyDiscordBot() { + if (! App::isProduction()) { + return; + } $this->log('Notifying Discord bot'); $commit = $this->repo->getHash($this->settings->branch); @@ -562,21 +690,33 @@ public function notifyDiscordBot() 'message' => $this->repo->getMessage($commit), 'mapSwitch' => $this->mapSwitch, 'commit' => $commit, - 'error' => !!$this->error, - 'cancelled' => false, + 'error' => (bool) $this->error, + 'cancelled' => $this->cancelled, 'mergeConflicts' => $this->testMergeConflicts, ]; - DiscordBot::export('wireci/build_finished', 'POST', $data); + try { + DiscordBot::export('wireci/build_finished', 'POST', $data); + } catch (\Throwable) { + // ignore + } } - public function notifyGameOfMapSwitch() + private function notifyGameOfMapSwitch() { + $this->checkCancelled(); + if (! App::isProduction()) { + return; + } $this->log('Notifying game of map switch'); - GameBridge::relay($this->server->server_id, [ - 'type' => 'mapSwitchDone', - 'map' => $this->error ? 'FAILED' : $this->settings->map, - ]); + // TODO: some way to cancel bridge comm + GameBridge::create() + ->target($this->server->server_id) + ->message([ + 'type' => 'mapSwitchDone', + 'map' => $this->error ? 'FAILED' : $this->settings->map_id, + ]) + ->sendAndForget(); } } diff --git a/app/Libraries/GameBuilder/CancelledException.php b/app/Libraries/GameBuilder/CancelledException.php new file mode 100644 index 0000000..0db5641 --- /dev/null +++ b/app/Libraries/GameBuilder/CancelledException.php @@ -0,0 +1,7 @@ +build = $build; + $this->repoShared = storage_path('app/game-builder/shared-git'); $this->repoDir = "$serverDir/repo"; - $this->repoSecretDir = "{$this->repoDir}/+secret"; + + if (File::missing($this->repoShared)) { + File::makeDirectory($this->repoShared); + } } - private function run(array $cmd, string $cwd = '') + private function run(array $cmd, string $cwd = '', int $timeout = 60, array $env = []) { - if (!$cwd) $cwd = $this->repoDir; - $process = new Process($cmd, $cwd); - $process->mustRun(); + $this->build->checkCancelled(); + if (! $cwd) { + $cwd = $this->repoDir; + } + + $process = new Process($cmd, $cwd, timeout: $timeout, env: $env); + $this->build->runProcess($process); + return trim($process->getOutput(), " \n\r\t\v\0\""); } - public function init(string $branch = 'master') + private function getRepoUrl(string $url) { - if (File::exists($this->repoDir)) { - return; + if (! str_starts_with($url, 'http')) { + $url = "https://$url"; + } + if (str_ends_with($url, '.git')) { + $url = substr($url, 0, -4); + } + $urlParts = parse_url($url); + $githubToken = config('github.user_token'); + $remoteUrl = "{$urlParts['host']}{$urlParts['path']}"; + $remoteUrlWithAuth = "{$this->userName}:$githubToken@$remoteUrl"; + + return (object) [ + 'base' => $remoteUrl, + 'full' => "https://$remoteUrlWithAuth", + 'slug' => Str::slug($remoteUrl), + ]; + } + + private function updateReference(object $repoUrl) + { + $refDir = "{$this->repoShared}/{$repoUrl->slug}"; + if (File::exists($refDir)) { + $this->run(['git', 'fetch', '--all'], cwd: $refDir); + + } else { + $this->run([ + 'git', 'clone', '--bare', + '-c', "user.name={$this->userName}", + '-c', "user.email={$this->userEmail}", + $repoUrl->full, $repoUrl->slug, + ], cwd: $this->repoShared, timeout: 300); } + } - File::makeDirectory($this->repoDir, recursive: true); + public function init() + { + $repoUrl = $this->getRepoUrl($this->repoUrl); + $this->updateReference($repoUrl); - $process = new Process([ - 'git', 'clone', '--recurse-submodules', - '-b', $branch, - $this->remoteUrl, $this->repoDir - ], timeout: 300); - $process->mustRun(); + if (File::missing($this->repoDir)) { + File::makeDirectory($this->repoDir, recursive: true); - $process = new Process(['git', 'config', 'user.name', 'robuddybot'], $this->repoDir); - $process->mustRun(); - $process = new Process(['git', 'config', 'user.email', 'robuddybot@goonhub.com'], $this->repoDir); + $this->run([ + 'git', 'clone', + '-c', "user.name={$this->userName}", + '-c', "user.email={$this->userEmail}", + '--reference', "{$this->repoShared}/{$repoUrl->slug}", + $repoUrl->full, $this->repoDir, + ], timeout: 300); + } + + $process = Process::fromShellCommandline( + 'git config --file .gitmodules --get-regexp path | awk \'{ print $1 }\'', + $this->repoDir + ); $process->mustRun(); + $submodules = explode("\n", $process->getOutput()); + foreach ($submodules as $submodule) { + if (empty($submodule)) { + continue; + } + + preg_match('/submodule\.(.*?)\./i', $submodule, $matches); + $key = $matches[1]; + $path = $this->run([ + 'git', 'config', '--file', '.gitmodules', + '--get', "submodule.$key.path", + ]); + $url = $this->run([ + 'git', 'config', '--file', '.gitmodules', + '--get', "submodule.$key.url", + ]); + + $subRepoUrl = $this->getRepoUrl($url); + $this->updateReference($subRepoUrl); + + if (File::isEmptyDirectory("{$this->repoDir}/$path")) { + $this->run([ + 'git', 'submodule', 'update', '--init', + '--reference', "{$this->repoShared}/{$subRepoUrl->slug}", + '--', $path, + ], timeout: 300); + } + } } public function fetch() { - return $this->run(['git', 'fetch', '--recurse-submodules']); + return $this->run([ + 'git', 'fetch', '--recurse-submodules', 'origin', + ], timeout: 300); } - public function update() + public function reset() { return $this->run(['git', 'reset', '--recurse-submodules', '--hard', '@{u}']); } - public function getBranch(bool $secret = false): string - { - return $this->run( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], - $secret ? $this->repoSecretDir : $this->repoDir - ); - } - - public function getHash(string $branch = null): string + public function getHash(?string $branch = null): string { return $this->run(['git', 'rev-parse', $branch ? $branch : '@']); } @@ -80,6 +159,11 @@ public function checkout(string $branch) return $this->run(['git', 'checkout', '--recurse-submodules', $branch]); } + public function checkoutRemote(string $branch) + { + return $this->run(['git', 'checkout', '--recurse-submodules', '-f', '-B', $branch, '--track', "origin/$branch"]); + } + public function createAndCheckoutBranch(string $branch) { return $this->run(['git', 'checkout', '--recurse-submodules', '-b', $branch]); @@ -92,7 +176,7 @@ public function fetchPr(int $prId) public function resetBranchToCommit(string $commit) { - return $this->run(['git', 'reset', '--recurse-submodules', '--hard', $commit]); + return $this->run(['git', 'reset', '--hard', $commit]); } public function merge(string $branch) @@ -112,7 +196,7 @@ public function abortMerge() public function commit(string $message) { - return $this->run(['git', 'commit', '-m', $message]); + return $this->run(['git', 'commit', '--no-gpg-sign', '-m', $message]); } public function getMessage(string $commit) diff --git a/app/ModelFilters/GameBuildFilter.php b/app/ModelFilters/GameBuildFilter.php new file mode 100644 index 0000000..6be4f3c --- /dev/null +++ b/app/ModelFilters/GameBuildFilter.php @@ -0,0 +1,80 @@ + [input_key1, input_key2]]. + * + * @var array + */ + public $relations = []; + + public function id($val) + { + return $this->where('id', $val); + } + + public function server($val) + { + return $this->where('server_id', $val); + } + + public function startedBy($val) + { + return $this->related('startedBy', function ($query) use ($val) { + return $query->where('name', 'ILIKE', '%'.$val.'%') + ->orWhere('ckey', 'ILIKE', '%'.$val.'%'); + }); + } + + public function branch($val) + { + return $this->where('branch', $val); + } + + public function commit($val) + { + return $this->where('commit', $val); + } + + public function mapId($val) + { + return $this->where('map_id', $val); + } + + public function failed($val) + { + return $this->where('failed', $val); + } + + public function cancelled($val) + { + return $this->where('cancelled', $val); + } + + public function mapSwitch($val) + { + return $this->where('map_switch', $val); + } + + public function cancelledBy($val) + { + return $this->related('cancelledBy', function ($query) use ($val) { + return $query->where('name', 'ILIKE', '%'.$val.'%') + ->orWhere('ckey', 'ILIKE', '%'.$val.'%'); + }); + } + + public function endedAt($val) + { + return $this->filterDate('ended_at', $val); + } +} diff --git a/app/ModelFilters/GameBuildSettingFilter.php b/app/ModelFilters/GameBuildSettingFilter.php new file mode 100644 index 0000000..d0ea9d4 --- /dev/null +++ b/app/ModelFilters/GameBuildSettingFilter.php @@ -0,0 +1,60 @@ + [input_key1, input_key2]]. + * + * @var array + */ + public $relations = []; + + public function id($val) + { + return $this->where('id', $val); + } + + public function server($val) + { + return $this->where('server_id', $val); + } + + public function branch($val) + { + return $this->where('branch', $val); + } + + public function byondMajor($val) + { + return $this->filterRange('byond_major', $val); + } + + public function byondMinor($val) + { + return $this->filterRange('byond_minor', $val); + } + + public function rustgVersion($val) + { + return $this->where('rustg_version', $val); + } + + public function rpMode($val) + { + return $this->where('rp_mode', $val); + } + + public function mapId($val) + { + return $this->where('map_id', $val); + } +} diff --git a/app/ModelFilters/GameBuildTestMergeFilter.php b/app/ModelFilters/GameBuildTestMergeFilter.php new file mode 100644 index 0000000..6b1b013 --- /dev/null +++ b/app/ModelFilters/GameBuildTestMergeFilter.php @@ -0,0 +1,55 @@ + [input_key1, input_key2]]. + * + * @var array + */ + public $relations = []; + + public function id($val) + { + return $this->where('id', $val); + } + + public function pr($val) + { + return $this->where('pr_id', $val); + } + + public function server($val) + { + return $this->where('server_id', $val); + } + + public function commit($val) + { + return $this->where('commit', $val); + } + + public function addedBy($val) + { + return $this->related('addedBy', function ($query) use ($val) { + return $query->where('name', 'ILIKE', '%'.$val.'%') + ->orWhere('ckey', 'ILIKE', '%'.$val.'%'); + }); + } + + public function updatedBy($val) + { + return $this->related('updatedBy', function ($query) use ($val) { + return $query->where('name', 'ILIKE', '%'.$val.'%') + ->orWhere('ckey', 'ILIKE', '%'.$val.'%'); + }); + } +} diff --git a/app/Models/GameBuild.php b/app/Models/GameBuild.php index b31e657..331f6d8 100644 --- a/app/Models/GameBuild.php +++ b/app/Models/GameBuild.php @@ -2,30 +2,86 @@ namespace App\Models; +use EloquentFilter\Filterable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property-read \App\Models\GameServer|null $gameServer + * @property-read \App\Models\Map|null $map + * @property-read \App\Models\GameAdmin|null $startedBy + * @property-read \App\Models\GameAdmin|null $cancelledBy + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild query() + * + * @property int $id + * @property string $server_id + * @property int|null $started_by + * @property string|null $branch + * @property string|null $commit + * @property string|null $map_id + * @property bool $failed + * @property bool $cancelled + * @property bool $map_switch + * @property int|null $cancelled_by + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property \Illuminate\Support\Carbon|null $ended_at + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereBranch($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereCancelled($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereCommit($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereEndedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereFailed($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereMap($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereServerId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereStartedBy($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereUpdatedAt($value) + * + * @property string|null $map_id + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereMapId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild filter(array $input = [], $filter = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild paginateFilter($perPage = null, $columns = [], $pageName = 'page', $page = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild simplePaginateFilter($perPage = null, $columns = [], $pageName = 'page', $page = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereBeginsWith($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereCancelledBy($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereEndsWith($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereLike($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuild whereMapSwitch($value) + * + * @mixin \Eloquent + */ class GameBuild extends Model { - use HasFactory; + use Filterable, HasFactory; protected $casts = [ 'ended_at' => 'datetime', ]; - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function gameServer() + public function gameServer(): BelongsTo { return $this->belongsTo(GameServer::class, 'server_id', 'server_id'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function startedBy() + public function startedBy(): BelongsTo { return $this->belongsTo(GameAdmin::class, 'started_by'); } + + public function map(): BelongsTo + { + return $this->belongsTo(Map::class, 'map_id', 'map_id'); + } + + public function cancelledBy(): BelongsTo + { + return $this->belongsTo(GameAdmin::class, 'cancelled_by'); + } } diff --git a/app/Models/GameBuildSecret.php b/app/Models/GameBuildSecret.php new file mode 100644 index 0000000..a96d33a --- /dev/null +++ b/app/Models/GameBuildSecret.php @@ -0,0 +1,28 @@ +|GameBuildSecret newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret query() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret whereKey($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSecret whereValue($value) + * + * @mixin \Eloquent + */ +class GameBuildSecret extends Model +{ + // +} diff --git a/app/Models/GameBuildSetting.php b/app/Models/GameBuildSetting.php index da3b121..66a5d21 100644 --- a/app/Models/GameBuildSetting.php +++ b/app/Models/GameBuildSetting.php @@ -2,18 +2,64 @@ namespace App\Models; +use EloquentFilter\Filterable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property-read \App\Models\GameServer|null $gameServer + * @property-read \App\Models\Map|null $map + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting query() + * + * @property int $id + * @property string $server_id + * @property string $branch + * @property int $byond_major + * @property int $byond_minor + * @property string $rustg_version + * @property bool $rp_mode + * @property string|null $map_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereBranch($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereByondMajor($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereByondMinor($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereMap($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereRpMode($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereRustgVersion($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereServerId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereUpdatedAt($value) + * + * @property string|null $map_id + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereMapId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting filter(array $input = [], $filter = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting paginateFilter($perPage = null, $columns = [], $pageName = 'page', $page = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting simplePaginateFilter($perPage = null, $columns = [], $pageName = 'page', $page = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereBeginsWith($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereEndsWith($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildSetting whereLike($column, $value, $boolean = 'and') + * + * @mixin \Eloquent + */ class GameBuildSetting extends Model { - use HasFactory; + use Filterable, HasFactory; - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function gameServer() + public function gameServer(): BelongsTo { return $this->belongsTo(GameServer::class, 'server_id', 'server_id'); } + + public function map(): BelongsTo + { + return $this->belongsTo(Map::class, 'map_id', 'map_id'); + } } diff --git a/app/Models/GameBuildTestMerge.php b/app/Models/GameBuildTestMerge.php index 909e9b6..bd6491a 100644 --- a/app/Models/GameBuildTestMerge.php +++ b/app/Models/GameBuildTestMerge.php @@ -2,12 +2,48 @@ namespace App\Models; +use EloquentFilter\Filterable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +/** + * @property-read \App\Models\GameAdmin|null $addedBy + * @property-read \App\Models\GameServer|null $gameServer + * @property-read \App\Models\GameAdmin|null $updatedBy + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge query() + * + * @property int $id + * @property int $pr_id + * @property string $server_id + * @property int|null $added_by + * @property int|null $updated_by + * @property string|null $commit + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereAddedBy($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereCommit($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge wherePrId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereServerId($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereUpdatedBy($value) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge filter(array $input = [], $filter = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge paginateFilter($perPage = null, $columns = [], $pageName = 'page', $page = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge simplePaginateFilter($perPage = null, $columns = [], $pageName = 'page', $page = null) + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereBeginsWith($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereEndsWith($column, $value, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|GameBuildTestMerge whereLike($column, $value, $boolean = 'and') + * + * @mixin \Eloquent + */ class GameBuildTestMerge extends Model { - use HasFactory; + use Filterable, HasFactory; protected $table = 'game_build_test_merges'; diff --git a/app/Models/GameServer.php b/app/Models/GameServer.php index 1fec309..f14081a 100644 --- a/app/Models/GameServer.php +++ b/app/Models/GameServer.php @@ -5,6 +5,7 @@ use EloquentFilter\Filterable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * @property int $id @@ -39,6 +40,8 @@ * @method static \Illuminate\Database\Eloquent\Builder|GameServer whereShortName($value) * @method static \Illuminate\Database\Eloquent\Builder|GameServer whereUpdatedAt($value) * + * @property-read \App\Models\GameBuildSetting|null $gameBuildSetting + * * @mixin \Eloquent */ class GameServer extends Model @@ -51,4 +54,9 @@ public function getByondLinkAttribute() { return 'byond://'.$this->address.':'.$this->port; } + + public function gameBuildSetting(): HasOne + { + return $this->hasOne(GameBuildSetting::class, 'server_id', 'server_id'); + } } diff --git a/app/Traits/ManagesGameBuildSettings.php b/app/Traits/ManagesGameBuildSettings.php new file mode 100644 index 0000000..cc8c2db --- /dev/null +++ b/app/Traits/ManagesGameBuildSettings.php @@ -0,0 +1,37 @@ +server_id = $request['server_id']; + $setting->branch = $request['branch']; + $setting->byond_major = $request['byond_major']; + $setting->byond_minor = $request['byond_minor']; + $setting->rustg_version = $request['rustg_version']; + $setting->rp_mode = isset($request['rp_mode']) ? $request['rp_mode'] : false; + $setting->map_id = isset($request['map_id']) ? $request['map_id'] : null; + $setting->save(); + + return new GameBuildSettingResource($setting); + } + + private function updateSetting(GameBuildSettingUpdateRequest $request, GameBuildSetting $setting) + { + foreach ($request->all() as $key => $val) { + $setting[$key] = $val; + } + + $setting->save(); + + return new GameBuildSettingResource($setting); + } +} diff --git a/app/Traits/ManagesGameBuildTestMerges.php b/app/Traits/ManagesGameBuildTestMerges.php new file mode 100644 index 0000000..bbd6f20 --- /dev/null +++ b/app/Traits/ManagesGameBuildTestMerges.php @@ -0,0 +1,53 @@ +first(); + + $testMerge = new GameBuildTestMerge; + $testMerge->pr_id = $request['pr_id']; + $testMerge->server_id = $request['server_id']; + $testMerge->commit = isset($request['commit']) ? $request['commit'] : null; + $testMerge->addedBy()->associate($gameAdmin); + $testMerge->save(); + + return new GameBuildTestMergeResource($testMerge); + } + + private function updateTestMerge(GameBuildTestMergeUpdateRequest $request, GameBuildTestMerge $testMerge) + { + $serverId = $request->input('server_id', $testMerge->server_id); + $prId = $request->input('pr_id', $testMerge->pr_id); + $exists = GameBuildTestMerge::whereNot('id', $testMerge->id) + ->where('pr_id', $prId) + ->where('server_id', $serverId) + ->exists(); + if ($exists) { + throw new \Exception('A test merge with that PR ID already exists for that server.'); + } + + foreach ($request->all() as $key => $val) { + if ($key === 'game_admin_id' || $key === 'game_admin_ckey') { + continue; + } + $testMerge[$key] = $val; + } + + $gameAdmin = GameAdmin::where('ckey', ckey($request['game_admin_ckey']))->first(); + + $testMerge->updatedBy()->associate($gameAdmin); + $testMerge->save(); + + return new GameBuildTestMergeResource($testMerge); + } +} diff --git a/app/Traits/ManagesGameBuilds.php b/app/Traits/ManagesGameBuilds.php new file mode 100644 index 0000000..d6cf8bd --- /dev/null +++ b/app/Traits/ManagesGameBuilds.php @@ -0,0 +1,40 @@ +firstOrFail(); + $server = GameServer::where('server_id', $request['server_id'])->firstOrFail(); + $setting = GameBuildSetting::where('server_id', $request['server_id'])->firstOrFail(); + + $mapSwitch = false; + if (! empty($request['map'])) { + $mapSwitch = true; + $setting->map_id = $request['map']; + $setting->save(); + } + + GameBuildJob::dispatch($admin, $server, $mapSwitch); + } + + private function cancelBuild(GameBuildCancelRequest $request) + { + if (! GameBuildJob::isBuilding($request['server_id'])) { + return abort(404, 'No build in process'); + } + + $admin = GameAdmin::where('ckey', $request['game_admin_ckey'])->firstOrFail(); + GameBuildBuild::cancel($request['server_id'], $admin->id); + } +} diff --git a/database/migrations/2024_07_01_210738_create_game_builds_table.php b/database/migrations/2024_07_01_210738_create_game_builds_table.php index d3d1a50..0bde497 100644 --- a/database/migrations/2024_07_01_210738_create_game_builds_table.php +++ b/database/migrations/2024_07_01_210738_create_game_builds_table.php @@ -17,14 +17,18 @@ public function up(): void $table->integer('started_by')->nullable(); $table->text('branch')->nullable(); $table->text('commit')->nullable(); - $table->text('map')->nullable(); + $table->text('map_id')->nullable(); $table->boolean('failed')->default(false); $table->boolean('cancelled')->default(false); + $table->boolean('map_switch')->default(false); + $table->integer('cancelled_by')->nullable(); $table->timestamps(); $table->timestamp('ended_at')->nullable(); $table->foreign('server_id')->references('server_id')->on('game_servers'); + $table->foreign('map_id')->references('map_id')->on('maps'); $table->foreign('started_by')->references('id')->on('game_admins'); + $table->foreign('cancelled_by')->references('id')->on('game_admins'); }); } diff --git a/database/migrations/2024_07_01_231855_create_game_build_test_merges.php b/database/migrations/2024_07_01_231855_create_game_build_test_merges.php index ddc7d8e..32ab574 100644 --- a/database/migrations/2024_07_01_231855_create_game_build_test_merges.php +++ b/database/migrations/2024_07_01_231855_create_game_build_test_merges.php @@ -23,6 +23,8 @@ public function up(): void $table->foreign('server_id')->references('server_id')->on('game_servers'); $table->foreign('added_by')->references('id')->on('game_admins'); $table->foreign('updated_by')->references('id')->on('game_admins'); + + $table->unique(['pr_id', 'server_id']); }); } diff --git a/database/migrations/2024_07_02_015102_create_game_build_settings.php b/database/migrations/2024_07_02_015102_create_game_build_settings.php index 58f3b37..b7442a8 100644 --- a/database/migrations/2024_07_02_015102_create_game_build_settings.php +++ b/database/migrations/2024_07_02_015102_create_game_build_settings.php @@ -21,15 +21,16 @@ public function up(): void $table->smallInteger('byond_minor'); $table->text('rustg_version'); $table->boolean('rp_mode')->default(false); - $table->text('map')->nullable(); + $table->text('map_id')->nullable(); $table->timestamps(); $table->foreign('server_id')->references('server_id')->on('game_servers'); + $table->foreign('map_id')->references('map_id')->on('maps'); }); $servers = GameServer::all(); foreach ($servers as $server) { - $setting = new GameBuildSetting(); + $setting = new GameBuildSetting; $setting->server_id = $server->server_id; if ($server->server_id === 'dev') { $setting->branch = 'develop'; diff --git a/database/migrations/2024_12_18_002148_create_game_build_secrets.php b/database/migrations/2024_12_18_002148_create_game_build_secrets.php new file mode 100644 index 0000000..799152c --- /dev/null +++ b/database/migrations/2024_12_18_002148_create_game_build_secrets.php @@ -0,0 +1,29 @@ +id(); + $table->text('key'); + $table->text('value'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('game_build_secrets'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index b214bb8..49ae67b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,99 +1,101 @@ services: - laravel.test: - build: - context: './docker/8.3' - dockerfile: Dockerfile - args: - WWWGROUP: '${WWWGROUP}' - image: 'sail-8.3/app' - extra_hosts: - - 'host.docker.internal:host-gateway' - ports: - - '${APP_PORT:-80}:80' - - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' - environment: - WWWUSER: '${WWWUSER}' - LARAVEL_SAIL: 1 - XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' - XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' - IGNITION_LOCAL_SITES_PATH: '${PWD}' - volumes: - - '.:/var/www/html' - - '../remote-game:/remote-game' - - '../goonhub-cdn/public:/cdn' - networks: - - sail - depends_on: - - pgsql - - redis - - memcached - - mailpit - pgsql: - image: 'postgres:16' - ports: - - '${FORWARD_DB_PORT:-5432}:5432' - environment: - PGPASSWORD: '${DB_PASSWORD:-secret}' - POSTGRES_DB: '${DB_DATABASE}' - POSTGRES_USER: '${DB_USERNAME}' - POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' - volumes: - - 'sail-pgsql:/var/lib/postgresql/data' - - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' - networks: - - sail - healthcheck: - test: - - CMD - - pg_isready - - '-q' - - '-d' - - '${DB_DATABASE}' - - '-U' - - '${DB_USERNAME}' - retries: 3 - timeout: 5s - redis: - image: 'redis:alpine' - ports: - - '${FORWARD_REDIS_PORT:-6379}:6379' - volumes: - - 'sail-redis:/data' - networks: - - sail - healthcheck: - test: - - CMD - - redis-cli - - ping - retries: 3 - timeout: 5s - memcached: - image: 'memcached:alpine' - ports: - - '${FORWARD_MEMCACHED_PORT:-11211}:11211' - networks: - - sail - mailpit: - image: 'axllent/mailpit:latest' - ports: - - '${FORWARD_MAILPIT_PORT:-1025}:1025' - - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' - networks: - - sail - browserless: - image: 'ghcr.io/browserless/chromium' - environment: - TOKEN: '6R0W53R135510' - ports: - - '${FORWARD_BROWSERLESS_PORT:-4444}:3000' - networks: - - sail + laravel.test: + build: + context: './docker/8.3' + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + WWWUSER: '${WWWUSER}' + image: 'sail-8.3/app' + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '${APP_PORT:-80}:80' + - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' + environment: + WWWUSER: '${WWWUSER}' + WWWGROUP: '${WWWGROUP}' + LARAVEL_SAIL: 1 + XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' + XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' + IGNITION_LOCAL_SITES_PATH: '${PWD}' + volumes: + - '.:/var/www/html' + - '../remote-game:/remote-game' # For testing CI + - '../goonhub-cdn/public:/cdn' # For testing CI + networks: + - sail + depends_on: + - pgsql + - redis + - memcached + - mailpit + pgsql: + image: 'postgres:16' + ports: + - '${FORWARD_DB_PORT:-5432}:5432' + environment: + PGPASSWORD: '${DB_PASSWORD:-secret}' + POSTGRES_DB: '${DB_DATABASE}' + POSTGRES_USER: '${DB_USERNAME}' + POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + volumes: + - 'sail-pgsql:/var/lib/postgresql/data' + - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' + networks: + - sail + healthcheck: + test: + - CMD + - pg_isready + - '-q' + - '-d' + - '${DB_DATABASE}' + - '-U' + - '${DB_USERNAME}' + retries: 3 + timeout: 5s + redis: + image: 'redis:alpine' + ports: + - '${FORWARD_REDIS_PORT:-6379}:6379' + volumes: + - 'sail-redis:/data' + networks: + - sail + healthcheck: + test: + - CMD + - redis-cli + - ping + retries: 3 + timeout: 5s + memcached: + image: 'memcached:alpine' + ports: + - '${FORWARD_MEMCACHED_PORT:-11211}:11211' + networks: + - sail + mailpit: + image: 'axllent/mailpit:latest' + ports: + - '${FORWARD_MAILPIT_PORT:-1025}:1025' + - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' + networks: + - sail + browserless: + image: 'ghcr.io/browserless/chromium' + environment: + TOKEN: '6R0W53R135510' + ports: + - '${FORWARD_BROWSERLESS_PORT:-4444}:3000' + networks: + - sail networks: - sail: - driver: bridge + sail: + driver: bridge volumes: - sail-pgsql: - driver: local - sail-redis: - driver: local + sail-pgsql: + driver: local + sail-redis: + driver: local diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile index ab8e929..1303bfc 100644 --- a/docker/8.3/Dockerfile +++ b/docker/8.3/Dockerfile @@ -2,8 +2,9 @@ FROM ubuntu:22.04 LABEL maintainer="Taylor Otwell" -ARG WWWGROUP -ARG NODE_VERSION=23 +ARG WWWGROUP=1000 +ARG WWWUSER=1000 +ARG NODE_VERSION="23.4.0" ARG POSTGRES_VERSION=16 WORKDIR /var/www/html @@ -26,28 +27,18 @@ RUN apt-get update \ && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ && apt-get update \ && apt-get install -y php8.3-cli php8.3-dev \ - php8.3-pgsql php8.3-sqlite3 php8.3-gd \ - php8.3-curl \ - php8.3-imap php8.3-mysql php8.3-mbstring \ - php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ - php8.3-intl php8.3-readline \ - php8.3-ldap \ - php8.3-msgpack php8.3-igbinary php8.3-redis php8.3-swoole \ - php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug \ + php8.3-pgsql php8.3-sqlite3 php8.3-gd \ + php8.3-curl \ + php8.3-imap php8.3-mysql php8.3-mbstring \ + php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ + php8.3-intl php8.3-readline \ + php8.3-ldap \ + php8.3-msgpack php8.3-igbinary php8.3-redis php8.3-swoole \ + php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug \ && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL "https://deb.nodesource.com/setup_$NODE_VERSION.x" -o nodesource_setup.sh \ - && bash nodesource_setup.sh \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g pnpm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ - && apt-get install -y yarn \ && apt-get install -y mysql-client \ && apt-get install -y postgresql-client-$POSTGRES_VERSION \ && apt-get install -y ffmpeg build-essential libasound2-dev libpulse-dev lame \ @@ -56,58 +47,65 @@ RUN apt-get update \ RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u $WWWUSER sail + +# Node stuff via NVM +USER sail +SHELL ["/bin/bash", "--login", "-c"] +ENV NVM_DIR /home/sail/.nvm +RUN curl https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && \ + . $NVM_DIR/nvm.sh && \ + nvm install --default $NODE_VERSION && \ + nvm use default && \ + npm install -g npm pnpm bun yarn +USER root ########################################### # Custom stuff ########################################## # Youtube DLP -RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \ - chmod a+rwx /usr/local/bin/yt-dlp +RUN curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \ + chmod a+rwx /usr/local/bin/yt-dlp ## Dectalk -RUN cd /home/sail && \ - git clone https://github.com/dectalk/dectalk.git && \ - cd dectalk/src && \ - autoreconf -si && \ - ./configure && \ - make -j && \ - cd /home/sail && \ - chown -R sail:sail dectalk && \ - ln -s /home/sail/dectalk/dist/say /usr/local/bin/dectalk +RUN cd /usr/local/src && \ + curl -fsSL https://github.com/dectalk/dectalk/releases/download/2023-10-30/ubuntu-latest.tar.gz -o dectalk.tar.gz && \ + mkdir dectalk && \ + tar -xzf dectalk.tar.gz -C dectalk/ && \ + rm dectalk.tar.gz && \ + chmod -R +x dectalk && \ + ln -s /usr/local/src/dectalk/say /usr/local/bin/dectalk # Chrome -RUN curl https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb --output chrome.deb && \ - apt-get install -y ./chrome.deb && \ - rm chrome.deb +RUN curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o chrome.deb && \ + apt-get install -y ./chrome.deb && \ + rm chrome.deb # Chromedriver RUN CHROMEDRIVER_VERSION=$(curl https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE) && \ - curl -L https://storage.googleapis.com/chrome-for-testing-public/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip --output chromedriver-linux64.zip && \ - unzip chromedriver-linux64.zip && \ - rm chromedriver-linux64.zip && \ - chmod +x chromedriver-linux64/chromedriver && \ - mv chromedriver-linux64/chromedriver /usr/local/bin + curl -fsSL https://storage.googleapis.com/chrome-for-testing-public/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip -o chromedriver-linux64.zip && \ + unzip chromedriver-linux64.zip && \ + rm chromedriver-linux64.zip && \ + chmod +x chromedriver-linux64/chromedriver && \ + mv chromedriver-linux64/chromedriver /usr/local/bin # Packages for compiling the game with Byond RUN dpkg --add-architecture i386 && \ apt-get update && \ apt-get install -y rsync gcc-multilib lib32stdc++6 zlib1g-dev:i386 libssl-dev:i386 pkg-config:i386 libstdc++6 libstdc++6:i386 -# Configure git credentials for Goonstation and install Cargo +# Install Cargo for building Rust-G USER sail -RUN git config --global credential.helper store && \ - echo "https://robuddybot:foo@github.com" > ~/.git-credentials && \ - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ ~/.cargo/bin/rustup target add i686-unknown-linux-gnu && \ - chmod -R 777 ~/.cargo + chmod -R 770 ~/.cargo USER root # Clean up apt cache RUN apt-get -y autoremove && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* COPY start-container /usr/local/bin/start-container COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf diff --git a/routes/api.php b/routes/api.php index 79cdcfa..9dfa48c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,9 @@ use App\Http\Controllers\Api\DectalkController; use App\Http\Controllers\Api\GameAdminRanksController; use App\Http\Controllers\Api\GameAdminsController; +use App\Http\Controllers\Api\GameBuildsController; +use App\Http\Controllers\Api\GameBuildSettingsController; +use App\Http\Controllers\Api\GameBuildTestMergesController; use App\Http\Controllers\Api\GameRoundsController; use App\Http\Controllers\Api\GauntletController; use App\Http\Controllers\Api\JobBansController; @@ -175,4 +178,22 @@ Route::controller(ServerPerformanceController::class)->prefix('server-performance')->group(function () { Route::get('/', 'index'); }); + Route::controller(GameBuildsController::class)->prefix('game-builds')->group(function () { + Route::get('/', 'index'); + Route::get('/status', 'status'); + Route::post('/build', 'build'); + Route::post('/cancel', 'cancel'); + }); + Route::controller(GameBuildSettingsController::class)->prefix('game-build-settings')->group(function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::put('/{setting}', 'update'); + Route::delete('/{setting}', 'destroy'); + }); + Route::controller(GameBuildTestMergesController::class)->prefix('game-build-test-merges')->group(function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::put('/{testMerge}', 'update'); + Route::delete('/{testMerge}', 'destroy'); + }); });