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
-
+
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 @@