diff --git a/README.md b/README.md index 000134c64..fe59db3d0 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Special thanks to our biggest sponsors, [CCCareers](https://cccareers.org/) and Tyler Whitesides NiftyCo Imre Ujlaki -Ilias Ism +Ilias Ism Paweł Pierścionek Michael Mazurczak diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 4def34ac6..2791a86f4 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -99,7 +99,7 @@ class Init extends Command return; } try { - Http::get("https://undead.coollabs.io/coolify/v4/alive?appId=$id&version=$version"); + Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version"); echo "I am alive!\n"; } catch (\Throwable $e) { echo "Error in alive: {$e->getMessage()}\n"; @@ -142,83 +142,83 @@ class Init extends Command try { $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($applications as $application) { - echo "Deleting stucked application: {$application->name}\n"; + echo "Deleting stuck application: {$application->name}\n"; $application->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked application: {$e->getMessage()}\n"; + echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($postgresqls as $postgresql) { - echo "Deleting stucked postgresql: {$postgresql->name}\n"; + echo "Deleting stuck postgresql: {$postgresql->name}\n"; $postgresql->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked postgresql: {$e->getMessage()}\n"; + echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { $redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($redis as $redis) { - echo "Deleting stucked redis: {$redis->name}\n"; + echo "Deleting stuck redis: {$redis->name}\n"; $redis->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked redis: {$e->getMessage()}\n"; + echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; } try { $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mongodbs as $mongodb) { - echo "Deleting stucked mongodb: {$mongodb->name}\n"; + echo "Deleting stuck mongodb: {$mongodb->name}\n"; $mongodb->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked mongodb: {$e->getMessage()}\n"; + echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; } try { $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mysqls as $mysql) { - echo "Deleting stucked mysql: {$mysql->name}\n"; + echo "Deleting stuck mysql: {$mysql->name}\n"; $mysql->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked mysql: {$e->getMessage()}\n"; + echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; } try { $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mariadbs as $mariadb) { - echo "Deleting stucked mariadb: {$mariadb->name}\n"; + echo "Deleting stuck mariadb: {$mariadb->name}\n"; $mariadb->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked mariadb: {$e->getMessage()}\n"; + echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; } try { $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($services as $service) { - echo "Deleting stucked service: {$service->name}\n"; + echo "Deleting stuck service: {$service->name}\n"; $service->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked service: {$e->getMessage()}\n"; + echo "Error in cleaning stuck service: {$e->getMessage()}\n"; } try { $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($serviceApps as $serviceApp) { - echo "Deleting stucked serviceapp: {$serviceApp->name}\n"; + echo "Deleting stuck serviceapp: {$serviceApp->name}\n"; $serviceApp->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked serviceapp: {$e->getMessage()}\n"; + echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($serviceDbs as $serviceDb) { - echo "Deleting stucked serviceapp: {$serviceDb->name}\n"; + echo "Deleting stuck serviceapp: {$serviceDb->name}\n"; $serviceDb->forceDelete(); } } catch (\Throwable $e) { - echo "Error in cleaning stucked serviceapp: {$e->getMessage()}\n"; + echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } // Cleanup any resources that are not attached to any environment or destination or server diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2a8e857e2..267572b39 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,12 +5,14 @@ namespace App\Console; use App\Jobs\CheckLogDrainContainerJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\DatabaseBackupJob; +use App\Jobs\ScheduledTaskJob; use App\Jobs\InstanceAutoUpdateJob; use App\Jobs\ContainerStatusJob; use App\Jobs\PullHelperImageJob; use App\Jobs\ServerStatusJob; use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; use Illuminate\Console\Scheduling\Schedule; @@ -30,6 +32,7 @@ class Kernel extends ConsoleKernel $this->check_resources($schedule); $this->check_scheduled_backups($schedule); $this->pull_helper_image($schedule); + $this->check_scheduled_tasks($schedule); } else { // Instance Jobs $schedule->command('horizon:snapshot')->everyFiveMinutes(); @@ -41,6 +44,7 @@ class Kernel extends ConsoleKernel $this->check_scheduled_backups($schedule); $this->check_resources($schedule); $this->pull_helper_image($schedule); + $this->check_scheduled_tasks($schedule); } } private function pull_helper_image($schedule) @@ -107,6 +111,32 @@ class Kernel extends ConsoleKernel } } + private function check_scheduled_tasks($schedule) { + $scheduled_tasks = ScheduledTask::all(); + if ($scheduled_tasks->isEmpty()) { + ray('no scheduled tasks'); + return; + } + foreach ($scheduled_tasks as $scheduled_task) { + $service = $scheduled_task->service()->get(); + $application = $scheduled_task->application()->get(); + + if (!$application && !$service) { + ray('application/service attached to scheduled task does not exist'); + $scheduled_task->delete(); + continue; + } + + if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) { + $scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency]; + } + $schedule->job(new ScheduledTaskJob( + task: $scheduled_task + ))->cron($scheduled_task->frequency)->onOneServer(); + } + + } + protected function commands(): void { $this->load(__DIR__ . '/Commands'); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index fcc570f30..1241751f0 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -3,14 +3,13 @@ namespace App\Http\Controllers; use App\Events\TestEvent; -use App\Models\InstanceSettings; -use App\Models\S3Storage; -use App\Models\StandalonePostgresql; use App\Models\TeamInvitation; use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Crypt; @@ -35,25 +34,25 @@ class Controller extends BaseController public function verify() { return view('auth.verify-email'); } - public function email_verify() { - request()->fulfill(); + public function email_verify(EmailVerificationRequest $request) { + $request->fulfill(); $name = request()->user()?->name; send_internal_notification("User {$name} verified their email address."); return redirect(RouteServiceProvider::HOME); } - public function forgot_password() { + public function forgot_password(Request $request) { if (is_transactional_emails_active()) { - $arrayOfRequest = request()->only(Fortify::email()); - request()->merge([ + $arrayOfRequest = $request->only(Fortify::email()); + $request->merge([ 'email' => Str::lower($arrayOfRequest['email']), ]); $type = set_transanctional_email_settings(); if (!$type) { return response()->json(['message' => 'Transactional emails are not active'], 400); } - request()->validate([Fortify::email() => 'required|email']); + $request->validate([Fortify::email() => 'required|email']); $status = Password::broker(config('fortify.passwords'))->sendResetLink( - request()->only(Fortify::email()) + $request->only(Fortify::email()) ); if ($status == Password::RESET_LINK_SENT) { return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => $status]); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c461c15f4..3999a7fcd 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -911,16 +911,18 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted $parsed = Toml::Parse($this->nixpacks_plan); // Do any modifications here $cmds = collect(data_get($parsed, 'phases.setup.cmds', [])); + $this->generate_env_variables(); data_set($parsed, 'phases.setup.cmds', $cmds); - $this->nixpacks_plan = json_encode($parsed); + $merged_envs = $this->env_args->merge(collect(data_get($parsed, 'variables', []))); + data_set($parsed, 'variables', $merged_envs->toArray()); + $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); } } } private function nixpacks_build_cmd() { - $this->generate_env_variables(); - $nixpacks_command = "nixpacks plan -f toml {$this->env_args}"; + $nixpacks_command = "nixpacks plan -f toml"; if ($this->application->build_command) { $nixpacks_command .= " --build-cmd \"{$this->application->build_command}\""; } @@ -939,21 +941,20 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted $this->env_args = collect([]); if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { - $this->env_args->push("--env {$env->key}={$env->value}"); + $this->env_args->put($env->key, $env->value); } foreach ($this->application->build_environment_variables as $env) { - $this->env_args->push("--env {$env->key}={$env->value}"); + $this->env_args->put($env->key, $env->value); } } else { foreach ($this->application->nixpacks_environment_variables_preview as $env) { - $this->env_args->push("--env {$env->key}={$env->value}"); + $this->env_args->put($env->key, $env->value); } foreach ($this->application->build_environment_variables_preview as $env) { - $this->env_args->push("--env {$env->key}={$env->value}"); + $this->env_args->put($env->key, $env->value); } } - - $this->env_args = $this->env_args->implode(' '); + // $this->env_args = $this->env_args->implode(' '); } private function generate_compose_file() @@ -1275,26 +1276,33 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { if ($this->application->build_pack === 'nixpacks') { $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d > {$this->workdir}/thegameplan.json"), "hidden" => true]); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d > /artifacts/thegameplan.json"), "hidden" => true]); if ($this->force_rebuild) { $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir}"), "hidden" => true + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir}"), "hidden" => true ]); } else { $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir}"), "hidden" => true + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir}"), "hidden" => true ]); } + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "rm /artifacts/thegameplan.json"), "hidden" => true]); } else { if ($this->force_rebuild) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"), "hidden" => true - ]); + $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; + $base64_build_command = base64_encode($build_command); } else { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"), "hidden" => true - ]); + $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; + $base64_build_command = base64_encode($build_command); } + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d > /artifacts/build.sh"), "hidden" => true + ], + [ + executeInDocker($this->deployment_uuid, "bash /artifacts/build.sh"), "hidden" => true + ] + ); } $dockerfile = base64_encode("FROM {$this->application->static_image} @@ -1320,6 +1328,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } }"); } + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d > {$this->workdir}/Dockerfile") @@ -1328,38 +1338,55 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d > {$this->workdir}/nginx.conf") ], [ - executeInDocker($this->deployment_uuid, "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"), "hidden" => true + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d > /artifacts/build.sh"), "hidden" => true + ], + [ + executeInDocker($this->deployment_uuid, "bash /artifacts/build.sh"), "hidden" => true ] ); } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"), "hidden" => true - ]); + $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d > /artifacts/build.sh"), "hidden" => true + ], + [ + executeInDocker($this->deployment_uuid, "bash /artifacts/build.sh"), "hidden" => true + ] + ); } else { if ($this->application->build_pack === 'nixpacks') { $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d > {$this->workdir}/thegameplan.json"), "hidden" => true]); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d > /artifacts/thegameplan.json"), "hidden" => true]); if ($this->force_rebuild) { $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir}"), "hidden" => true + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir}"), "hidden" => true ]); } else { $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir}"), "hidden" => true + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir}"), "hidden" => true ]); } + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "rm /artifacts/thegameplan.json"), "hidden" => true]); } else { if ($this->force_rebuild) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"), "hidden" => true - ]); + $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); } else { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"), "hidden" => true - ]); + $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); } + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d > /artifacts/build.sh"), "hidden" => true + ], + [ + executeInDocker($this->deployment_uuid, "bash /artifacts/build.sh"), "hidden" => true + ] + ); } } } @@ -1439,11 +1466,13 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->build_args = collect(["--build-arg SOURCE_COMMIT=\"{$this->commit}\""]); if ($this->pull_request_id === 0) { foreach ($this->application->build_environment_variables as $env) { - $this->build_args->push("--build-arg {$env->key}=\"{$env->value}\""); + $value = escapeshellarg($env->value); + $this->build_args->push("--build-arg {$env->key}={$value}"); } } else { foreach ($this->application->build_environment_variables_preview as $env) { - $this->build_args->push("--build-arg {$env->key}=\"{$env->value}\""); + $value = escapeshellarg($env->value); + $this->build_args->push("--build-arg {$env->key}={$value}"); } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php new file mode 100644 index 000000000..104b5f7e3 --- /dev/null +++ b/app/Jobs/ScheduledTaskJob.php @@ -0,0 +1,115 @@ +task = $task; + if ($service = $task->service()->first()) { + $this->resource = $service; + } else if ($application = $task->application()->first()) { + $this->resource = $application; + } + $this->team = Team::find($task->team_id); + } + + public function middleware(): array + { + return [new WithoutOverlapping($this->task->id)]; + } + + public function uniqueId(): int + { + return $this->task->id; + } + + public function handle(): void + { + try { + $this->task_log = ScheduledTaskExecution::create([ + 'scheduled_task_id' => $this->task->id, + ]); + + $this->server = $this->resource->destination->server; + + if ($this->resource->type() == 'application') { + $containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0); + if ($containers->count() > 0) { + $containers->each(function ($container) { + $this->containers[] = str_replace('/', '', $container['Names']); + }); + } + } + elseif ($this->resource->type() == 'service') { + $this->resource->applications()->get()->each(function ($application) { + if (str(data_get($application, 'status'))->contains('running')) { + $this->containers[] = data_get($application, 'name') . '-' . data_get($this->resource, 'uuid'); + } + }); + } + + if (count($this->containers) == 0) { + throw new \Exception('ScheduledTaskJob failed: No containers running.'); + } + + if (count($this->containers) > 1 && empty($this->task->container)) { + throw new \Exception('ScheduledTaskJob failed: More than one container exists but no container name was provided.'); + } + + foreach ($this->containers as $containerName) { + if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container . '-' . $this->resource->uuid)) { + $cmd = 'sh -c "' . str_replace('"', '\"', $this->task->command) . '"'; + $exec = "docker exec {$containerName} {$cmd}"; + $this->task_output = instant_remote_process([$exec], $this->server, true); + $this->task_log->update([ + 'status' => 'success', + 'message' => $this->task_output, + ]); + return; + } + } + + // No valid container was found. + throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); + + } catch (\Throwable $e) { + if ($this->task_log) { + $this->task_log->update([ + 'status' => 'failed', + 'message' => $this->task_output ?? $e->getMessage(), + ]); + } + send_internal_notification('ScheduledTaskJob failed with: ' . $e->getMessage()); + throw $e; + } + } +} diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b36936628..0c5828af3 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -209,7 +209,7 @@ class General extends Component public function updatedApplicationFqdn() { $this->resetDefaultLabels(false); - $this->dispatch('success', 'Labels reseted to default!'); + $this->dispatch('success', 'Labels reset to default!'); } public function submit($showToaster = true) { diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php new file mode 100644 index 000000000..697b14646 --- /dev/null +++ b/app/Livewire/Project/Database/Import.php @@ -0,0 +1,139 @@ +user()->id; + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + ]; + } + public function mount() + { + $this->parameters = get_route_parameters(); + $this->getContainers(); + } + + public function getContainers() + { + $this->containers = collect(); + if (!data_get($this->parameters, 'database_uuid')) { + abort(404); + } + + $resource = StandalonePostgresql::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + $resource = StandaloneRedis::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + $resource = StandaloneMongodb::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + $resource = StandaloneMysql::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + $resource = StandaloneMariadb::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + abort(404); + } + } + } + } + } + $this->resource = $resource; + $this->server = $this->resource->destination->server; + $this->container = $this->resource->uuid; + if (str(data_get($this, 'resource.status'))->startsWith('running')) { + $this->containers->push($this->container); + } + + if ($this->containers->count() > 1) { + $this->validated = false; + $this->validationMsg = 'The database service has more than one container running. Cannot import.'; + } + + if ( + $this->resource->getMorphClass() == 'App\Models\StandaloneRedis' + || $this->resource->getMorphClass() == 'App\Models\StandaloneMongodb' + ) { + $this->validated = false; + $this->validationMsg = 'This database type is not currently supported.'; + } + } + + public function runImport() + { + $this->validate([ + 'file' => 'required|file|max:102400' + ]); + + $this->importRunning = true; + $this->scpInProgress = true; + + try { + $uploadedFilename = $this->file->store('backup-import'); + $path = Storage::path($uploadedFilename); + $tmpPath = '/tmp/' . basename($uploadedFilename); + + // SCP the backup file to the server. + instant_scp($path, $tmpPath, $this->server); + $this->scpInProgress = false; + + $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + + switch ($this->resource->getMorphClass()) { + case 'App\Models\StandaloneMariadb': + $this->importCommands[] = "docker exec {$this->container} sh -c '{$this->mariadbRestoreCommand} < {$tmpPath}'"; + $this->importCommands[] = "rm {$tmpPath}"; + break; + case 'App\Models\StandaloneMysql': + $this->importCommands[] = "docker exec {$this->container} sh -c '{$this->mysqlRestoreCommand} < {$tmpPath}'"; + $this->importCommands[] = "rm {$tmpPath}"; + break; + case 'App\Models\StandalonePostgresql': + $this->importCommands[] = "docker exec {$this->container} sh -c '{$this->postgresqlRestoreCommand} {$tmpPath}'"; + $this->importCommands[] = "rm {$tmpPath}"; + break; + } + + $this->importCommands[] = "docker exec {$this->container} sh -c 'rm {$tmpPath}'"; + $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + if (!empty($this->importCommands)) { + $activity = remote_process($this->importCommands, $this->server, ignore_errors: true); + $this->dispatch('newMonitorActivity', $activity->id); + } + } catch (\Throwable $e) { + $this->validated = false; + $this->validationMsg = $e->getMessage(); + } + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php new file mode 100644 index 000000000..3cc5428b8 --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -0,0 +1,58 @@ + 'clear']; + protected $rules = [ + 'name' => 'required|string', + 'command' => 'required|string', + 'frequency' => 'required|string', + 'container' => 'nullable|string', + ]; + protected $validationAttributes = [ + 'name' => 'name', + 'command' => 'command', + 'frequency' => 'frequency', + 'container' => 'container', + ]; + + public function mount() + { + $this->parameters = get_route_parameters(); + } + + public function submit() + { + $this->validate(); + $isValid = validate_cron_expression($this->frequency); + if (!$isValid) { + $this->dispatch('error', 'Invalid Cron / Human expression.'); + return; + } + $this->dispatch('saveScheduledTask', [ + 'name' => $this->name, + 'command' => $this->command, + 'frequency' => $this->frequency, + 'container' => $this->container, + ]); + $this->clear(); + } + + public function clear() + { + $this->name = ''; + $this->command = ''; + $this->frequency = ''; + $this->container = ''; + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/All.php b/app/Livewire/Project/Shared/ScheduledTask/All.php new file mode 100644 index 000000000..4a876e72a --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/All.php @@ -0,0 +1,56 @@ + 'submit']; + + public function mount() + { + $this->parameters = get_route_parameters(); + $this->modalId = new Cuid2(7); + } + public function refreshTasks() + { + $this->resource->refresh(); + } + + public function submit($data) + { + try { + $task = new ScheduledTask(); + $task->name = $data['name']; + $task->command = $data['command']; + $task->frequency = $data['frequency']; + $task->container = $data['container']; + $task->team_id = currentTeam()->id; + + switch ($this->resource->type()) { + case 'application': + $task->application_id = $this->resource->id; + break; + case 'standalone-postgresql': + $task->standalone_postgresql_id = $this->resource->id; + break; + case 'service': + $task->service_id = $this->resource->id; + break; + } + $task->save(); + $this->refreshTasks(); + $this->dispatch('success', 'Scheduled task added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php new file mode 100644 index 000000000..9c1ec7cc5 --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -0,0 +1,27 @@ +selectedKey) { + $this->selectedKey = null; + return; + } + $this->selectedKey = $key; + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php new file mode 100644 index 000000000..23cb0e41a --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -0,0 +1,72 @@ + 'required|string', + 'task.command' => 'required|string', + 'task.frequency' => 'required|string', + 'task.container' => 'nullable|string', + ]; + protected $validationAttributes = [ + 'name' => 'name', + 'command' => 'command', + 'frequency' => 'frequency', + 'container' => 'container', + ]; + + public function mount() + { + $this->parameters = get_route_parameters(); + + if (data_get($this->parameters, 'application_uuid')) { + $this->type = 'application'; + $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + } else if (data_get($this->parameters, 'service_uuid')) { + $this->type = 'service'; + $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + } + + $this->modalId = new Cuid2(7); + $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); + } + + public function submit() + { + $this->validate(); + $this->task->save(); + $this->dispatch('success', 'Scheduled task updated successfully.'); + $this->dispatch('refreshTasks'); + } + + public function delete() + { + try { + $this->task->delete(); + + if ($this->type == 'application') { + return redirect()->route('project.application.configuration', $this->parameters); + } + else { + return redirect()->route('project.service.configuration', $this->parameters); + } + + } catch (\Exception $e) { + return handleError($e); + } + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index e0cba3764..c16adc7e6 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -315,6 +315,11 @@ class Application extends BaseModel return $this->hasMany(EnvironmentVariable::class)->where('is_preview', true)->where('key', 'like', 'NIXPACKS_%'); } + public function scheduled_tasks(): HasMany + { + return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); + } + public function private_key() { return $this->belongsTo(PrivateKey::class); diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php new file mode 100644 index 000000000..2ff391c59 --- /dev/null +++ b/app/Models/ScheduledTask.php @@ -0,0 +1,28 @@ +belongsTo(Service::class); + } + public function application() + { + return $this->belongsTo(Application::class); + } + public function latest_log(): HasOne + { + return $this->hasOne(ScheduledTaskExecution::class)->latest(); + } + public function executions(): HasMany + { + return $this->hasMany(ScheduledTaskExecution::class); + } +} diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php new file mode 100644 index 000000000..de13fefb0 --- /dev/null +++ b/app/Models/ScheduledTaskExecution.php @@ -0,0 +1,15 @@ +belongsTo(ScheduledTask::class); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 291a9d479..65b992b5a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -358,10 +358,10 @@ class Server extends BaseModel public function validateOS(): bool | Stringable { $os_release = instant_remote_process(['cat /etc/os-release'], $this); - $datas = collect(explode("\n", $os_release)); + $releaseLines = collect(explode("\n", $os_release)); $collectedData = collect([]); - foreach ($datas as $data) { - $item = Str::of($data)->trim(); + foreach ($releaseLines as $line) { + $item = Str::of($line)->trim(); $collectedData->put($item->before('=')->value(), $item->after('=')->lower()->replace('"', '')->value()); } $ID = data_get($collectedData, 'ID'); diff --git a/app/Models/Service.php b/app/Models/Service.php index eb0d96670..7f71ff865 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -396,6 +396,10 @@ class Service extends BaseModel } return null; } + public function scheduled_tasks(): HasMany + { + return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); + } public function environment_variables(): HasMany { return $this->hasMany(EnvironmentVariable::class)->orderBy('key', 'asc'); diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 99afcf5a7..da8ef812e 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -14,8 +14,8 @@ class EmailChannel { try { $this->bootConfigs($notifiable); - $recepients = $notifiable->getRecepients($notification); - if (count($recepients) === 0) { + $recipients = $notifiable->getRecepients($notification); + if (count($recipients) === 0) { throw new Exception('No email recipients found'); } @@ -24,7 +24,7 @@ class EmailChannel [], [], fn (Message $message) => $message - ->to($recepients) + ->to($recipients) ->subject($mailMessage->subject) ->html((string)$mailMessage->render()) ); @@ -35,8 +35,8 @@ class EmailChannel } ray($e->getMessage()); $message = "EmailChannel error: {$e->getMessage()}. Failed to send email to:"; - if (isset($recepients)) { - $message .= implode(', ', $recepients); + if (isset($recipients)) { + $message .= implode(', ', $recipients); } if (isset($mailMessage)) { $message .= " with subject: {$mailMessage->subject}"; diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 91e42c5be..f49c7cafc 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -67,6 +67,47 @@ function savePrivateKeyToFs(Server $server) return $location; } +function generateScpCommand(Server $server, string $source, string $dest) +{ + $user = $server->user; + $port = $server->port; + $privateKeyLocation = savePrivateKeyToFs($server); + $timeout = config('constants.ssh.command_timeout'); + $connectionTimeout = config('constants.ssh.connection_timeout'); + $serverInterval = config('constants.ssh.server_interval'); + + $scp_command = "timeout $timeout scp "; + $scp_command .= "-i {$privateKeyLocation} " + . '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' + . '-o PasswordAuthentication=no ' + . "-o ConnectTimeout=$connectionTimeout " + . "-o ServerAliveInterval=$serverInterval " + . '-o RequestTTY=no ' + . '-o LogLevel=ERROR ' + . "-P {$port} " + . "{$source} " + . "{$user}@{$server->ip}:{$dest}"; + + return $scp_command; +} +function instant_scp(string $source, string $dest, Server $server, $throwError = true) +{ + $timeout = config('constants.ssh.command_timeout'); + $scp_command = generateScpCommand($server, $source, $dest); + $process = Process::timeout($timeout)->run($scp_command); + $output = trim($process->output()); + $exitCode = $process->exitCode(); + if ($exitCode !== 0) { + if (!$throwError) { + return null; + } + return excludeCertainErrors($process->errorOutput(), $exitCode); + } + if ($output === 'null') { + $output = null; + } + return $output; +} function generateSshCommand(Server $server, string $command, bool $isMux = true) { $user = $server->user; diff --git a/config/livewire.php b/config/livewire.php index b1a3bf555..83229fcea 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -53,7 +53,9 @@ return [ 'temporary_file_upload' => [ 'disk' => null, // Example: 'local', 's3' | Default: 'default' - 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'rules' => [ // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'file', 'max:256000' + ], 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... diff --git a/config/sentry.php b/config/sentry.php index c771962da..4df15f841 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.185', + 'release' => '4.0.0-beta.186', // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index 836a1000b..be52135e7 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ id(); + $table->string('uuid')->unique(); + $table->boolean('enabled')->default(true); + $table->string('name'); + $table->string('command'); + $table->string('frequency'); + $table->string('container')->nullable(); + $table->timestamps(); + + $table->foreignId('application_id')->nullable(); + $table->foreignId('service_id')->nullable(); + $table->foreignId('team_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_tasks'); + } +}; diff --git a/database/migrations/2024_01_01_231053_create_scheduled_task_executions_table.php b/database/migrations/2024_01_01_231053_create_scheduled_task_executions_table.php new file mode 100644 index 000000000..27ace08d4 --- /dev/null +++ b/database/migrations/2024_01_01_231053_create_scheduled_task_executions_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('uuid')->unique(); + $table->enum('status', ['success', 'failed', 'running'])->default('running'); + $table->longText('message')->nullable(); + $table->foreignId('scheduled_task_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_task_executions'); + } +}; diff --git a/docker/dev-ssu/Dockerfile b/docker/dev-ssu/Dockerfile index f6f141271..cf79afe4d 100644 --- a/docker/dev-ssu/Dockerfile +++ b/docker/dev-ssu/Dockerfile @@ -37,3 +37,7 @@ RUN /bin/bash -c "if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ curl -L https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared \ ;fi" +RUN { \ + echo 'upload_max_filesize=256M'; \ + echo 'post_max_size=256M'; \ + } > /etc/php/current_version/cli/conf.d/upload-limits.ini \ No newline at end of file diff --git a/docker/prod-ssu/Dockerfile b/docker/prod-ssu/Dockerfile index 238fe5658..d5ba465b7 100644 --- a/docker/prod-ssu/Dockerfile +++ b/docker/prod-ssu/Dockerfile @@ -62,3 +62,8 @@ RUN /bin/bash -c "if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ echo 'arm64' && \ curl -L https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared \ ;fi" + +RUN { \ + echo 'upload_max_filesize=256M'; \ + echo 'post_max_size=256M'; \ + } > /etc/php/current_version/cli/conf.d/upload-limits.ini \ No newline at end of file diff --git a/resources/views/components/pricing-plans.blade.php b/resources/views/components/pricing-plans.blade.php index 7837bc0f5..3b37f6ef1 100644 --- a/resources/views/components/pricing-plans.blade.php +++ b/resources/views/components/pricing-plans.blade.php @@ -302,7 +302,7 @@
Once you connected your server, Coolify will start managing it and do a - lot of adminstrative tasks for you. You can also write your own scripts to + lot of administrative tasks for you. You can also write your own scripts to automate your server*.
@@ -384,7 +384,7 @@
Powerful API
- Programatically deploy, query, and manage your servers & resources. + Programmatically deploy, query, and manage your servers & resources. Integrate to your CI/CD pipelines, or build your own custom integrations. *
diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 5b529e7a2..baa46fa91 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -1,7 +1,7 @@