Merge branch 'next' into feat/healthcheck-cmd

This commit is contained in:
Aditya Tripathi authored and GitHub committed 2026-02-21 06:54:29 +05:30
commit 04283a03a0
192 files changed
+16780 -10634

No files matched your search

+47 -7
View File
@@ -112,12 +112,52 @@ class StartDatabaseProxy
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
try {
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
} catch (\RuntimeException $e) {
if ($this->isNonTransientError($e->getMessage())) {
$database->update(['is_public' => false]);
$team = data_get($database, 'environment.project.team')
?? data_get($database, 'service.environment.project.team');
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)
);
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
return;
}
throw $e;
}
}
private function isNonTransientError(string $message): bool
{
$nonTransientPatterns = [
'port is already allocated',
'address already in use',
'Bind for',
];
foreach ($nonTransientPatterns as $pattern) {
if (str_contains($message, $pattern)) {
return true;
}
}
return false;
}
}
@@ -25,7 +25,6 @@ class StopDatabaseProxy
$server = data_get($database, 'destination.server');
$uuid = $database->uuid;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$uuid = $database->service->uuid;
$server = data_get($database, 'service.server');
}
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
+41 -15
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Docker;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck;
use App\Events\ServiceChecked;
use App\Models\ApplicationPreview;
@@ -180,21 +181,30 @@ class GetContainersStatus
if ($database_id) {
$service_db = ServiceDatabase::where('id', $database_id)->first();
if ($service_db) {
$uuid = data_get($service_db, 'service.uuid');
if ($uuid) {
$isPublic = data_get($service_db, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($service_db);
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for {$service_db->service->name}", $this->server));
$proxyUuid = $service_db->uuid;
$isPublic = data_get($service_db, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($proxyUuid) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$proxyUuid";
} else {
return data_get($value, 'Name') === "/$proxyUuid-proxy";
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($service_db);
}
} else {
// Clean up orphaned proxy when is_public=false
$orphanedProxy = $this->containers->filter(function ($value, $key) use ($proxyUuid) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$proxyUuid";
} else {
return data_get($value, 'Name') === "/$proxyUuid-proxy";
}
})->first();
if ($orphanedProxy) {
StopDatabaseProxy::run($service_db);
}
}
}
@@ -235,7 +245,18 @@ class GetContainersStatus
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($database);
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
}
} else {
// Clean up orphaned proxy when is_public=false
$orphanedProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
})->first();
if ($orphanedProxy) {
StopDatabaseProxy::run($database);
}
}
} else {
@@ -393,6 +414,11 @@ class GetContainersStatus
'last_restart_type' => null,
]);
// Stop proxy if database was public
if ($database->is_public) {
StopDatabaseProxy::run($database);
}
$name = data_get($database, 'name');
$fqdn = data_get($database, 'fqdn');
+2 -1
View File
@@ -32,7 +32,8 @@ class OpenApi extends Command
echo $process->output();
$yaml = file_get_contents('openapi.yaml');
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
}
@@ -0,0 +1,222 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GenerateTestingSchema extends Command
{
protected $signature = 'schema:generate-testing {--connection=pgsql : The database connection to read from}';
protected $description = 'Generate SQLite testing schema from the PostgreSQL database';
private array $typeMap = [
'/\bbigint\b/' => 'INTEGER',
'/\binteger\b/' => 'INTEGER',
'/\bsmallint\b/' => 'INTEGER',
'/\bboolean\b/' => 'INTEGER',
'/character varying\(\d+\)/' => 'TEXT',
'/timestamp\(\d+\) without time zone/' => 'TEXT',
'/timestamp\(\d+\) with time zone/' => 'TEXT',
'/\bjsonb\b/' => 'TEXT',
'/\bjson\b/' => 'TEXT',
'/\buuid\b/' => 'TEXT',
'/double precision/' => 'REAL',
'/numeric\(\d+,\d+\)/' => 'REAL',
'/\bdate\b/' => 'TEXT',
];
private array $castRemovals = [
'::character varying',
'::text',
'::integer',
'::boolean',
'::timestamp without time zone',
'::timestamp with time zone',
'::numeric',
];
public function handle(): int
{
$connection = $this->option('connection');
if (DB::connection($connection)->getDriverName() !== 'pgsql') {
$this->error("Connection '{$connection}' is not PostgreSQL.");
return self::FAILURE;
}
$this->info('Reading schema from PostgreSQL...');
$tables = $this->getTables($connection);
$lastMigration = DB::connection($connection)
->table('migrations')
->orderByDesc('id')
->value('migration');
$output = [];
$output[] = '-- Generated by: php artisan schema:generate-testing';
$output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
$output[] = '-- Last migration: '.($lastMigration ?? 'none');
$output[] = '';
foreach ($tables as $table) {
$columns = $this->getColumns($connection, $table);
$output[] = $this->generateCreateTable($table, $columns);
}
$indexes = $this->getIndexes($connection, $tables);
foreach ($indexes as $index) {
$output[] = $index;
}
$output[] = '';
$output[] = '-- Migration records';
$migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
foreach ($migrations as $m) {
$migration = str_replace("'", "''", $m->migration);
$output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
}
$path = database_path('schema/testing-schema.sql');
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, implode("\n", $output)."\n");
$this->info("Schema written to {$path}");
$this->info(count($tables).' tables, '.count($migrations).' migration records.');
return self::SUCCESS;
}
private function getTables(string $connection): array
{
return collect(DB::connection($connection)->select(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
))->pluck('tablename')->toArray();
}
private function getColumns(string $connection, string $table): array
{
return DB::connection($connection)->select(
"SELECT column_name, data_type, character_maximum_length, column_default,
is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ?
ORDER BY ordinal_position",
[$table]
);
}
private function generateCreateTable(string $table, array $columns): string
{
$lines = [];
foreach ($columns as $col) {
$lines[] = ' '.$this->generateColumnDef($table, $col);
}
return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
}
private function generateColumnDef(string $table, object $col): string
{
$name = $col->column_name;
$sqliteType = $this->convertType($col);
// Auto-increment primary key for id columns
if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
}
$parts = ["\"{$name}\"", $sqliteType];
// Default value
$default = $col->column_default;
if ($default !== null && ! str_contains($default, 'nextval')) {
$default = $this->cleanDefault($default);
$parts[] = "DEFAULT {$default}";
}
// NOT NULL
if ($col->is_nullable === 'NO') {
$parts[] = 'NOT NULL';
}
return implode(' ', $parts);
}
private function convertType(object $col): string
{
$pgType = $col->data_type;
return match (true) {
in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
$pgType === 'boolean' => 'INTEGER',
in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
str_contains($pgType, 'timestamp') => 'TEXT',
in_array($pgType, ['json', 'jsonb']) => 'TEXT',
$pgType === 'uuid' => 'TEXT',
$pgType === 'double precision' => 'REAL',
$pgType === 'numeric' => 'REAL',
$pgType === 'date' => 'TEXT',
default => 'TEXT',
};
}
private function cleanDefault(string $default): string
{
foreach ($this->castRemovals as $cast) {
$default = str_replace($cast, '', $default);
}
// Remove array type casts like ::text[]
$default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
return $default;
}
private function getIndexes(string $connection, array $tables): array
{
$results = [];
$indexes = DB::connection($connection)->select(
"SELECT indexname, tablename, indexdef FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname"
);
foreach ($indexes as $idx) {
$def = $idx->indexdef;
// Skip primary key indexes
if (str_contains($def, '_pkey')) {
continue;
}
// Skip PG-specific indexes (GIN, GIST, expression indexes)
if (preg_match('/USING (gin|gist)/i', $def)) {
continue;
}
if (str_contains($def, '->>') || str_contains($def, '::')) {
continue;
}
// Convert to SQLite-compatible CREATE INDEX
$unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
// Extract columns from the index definition
if (preg_match('/\((.+)\)$/', $def, $m)) {
$cols = $m[1];
$results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
}
}
return $results;
}
}
@@ -19,6 +19,7 @@ use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
@@ -153,11 +154,14 @@ class ApplicationsController extends Controller
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application domains.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'],
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@@ -198,6 +202,7 @@ class ApplicationsController extends Controller
// 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'],
'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'],
@@ -315,11 +320,14 @@ class ApplicationsController extends Controller
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application domains.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'],
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@@ -359,6 +367,7 @@ class ApplicationsController extends Controller
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'],
'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'],
@@ -476,11 +485,14 @@ class ApplicationsController extends Controller
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application domains.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'],
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@@ -520,6 +532,7 @@ class ApplicationsController extends Controller
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'],
'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'],
@@ -635,7 +648,7 @@ class ApplicationsController extends Controller
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application domains.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'],
@@ -671,6 +684,7 @@ class ApplicationsController extends Controller
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
@@ -771,7 +785,7 @@ class ApplicationsController extends Controller
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application domains.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'],
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Health check enabled.'],
'health_check_path' => ['type' => 'string', 'description' => 'Health check path.'],
@@ -804,6 +818,7 @@ class ApplicationsController extends Controller
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
@@ -879,8 +894,8 @@ class ApplicationsController extends Controller
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
*/
#[OA\Post(
summary: 'Create (Docker Compose) (Deprecated)',
description: 'Create new application based on a docker-compose file (without git).',
summary: 'Create (Docker Compose)',
description: 'Deprecated: Use POST /api/v1/services instead.',
path: '/applications/dockercompose',
operationId: 'create-dockercompose-application',
deprecated: true,
@@ -987,7 +1002,7 @@ class ApplicationsController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1030,6 +1045,9 @@ class ApplicationsController extends Controller
$githubAppUuid = $request->github_app_uuid;
$useBuildServer = $request->use_build_server;
$isStatic = $request->is_static;
$isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled;
$isForceHttpsEnabled = $request->is_force_https_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
@@ -1130,39 +1148,63 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
$domainErrors = [];
foreach ($dockerComposeDomains as $index => $item) {
// Collect all URLs from all docker_compose_domains items
$urls = $dockerComposeDomains->flatMap(function ($item) {
$domainValue = data_get($item, 'domain');
if (filled($domainValue)) {
$urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim();
str($urls)->explode(',')->each(function ($url) use (&$domainErrors) {
$url = trim($url);
if (empty($url)) {
return;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$domainErrors[] = "Invalid URL: {$url}";
return;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
});
if (blank($domainValue)) {
return [];
}
return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.';
}
if (! empty($domainErrors)) {
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_domains' => $domainErrors,
],
'errors' => ['docker_compose_domains' => $errors],
], 422);
}
// Check for domain conflicts
if ($urls->isNotEmpty()) {
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => $result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
@@ -1187,6 +1229,18 @@ class ApplicationsController extends Controller
$application->settings->is_static = $isStatic;
$application->settings->save();
}
if (isset($isSpa)) {
$application->settings->is_spa = $isSpa;
$application->settings->save();
}
if (isset($isAutoDeployEnabled)) {
$application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled;
$application->settings->save();
}
if (isset($isForceHttpsEnabled)) {
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -1291,24 +1345,28 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Failed to generate Github App token.'], 400);
}
$repositories = collect();
$page = 1;
$repositories = loadRepositoryByPage($githubApp, $token, $page);
if ($repositories['total_count'] > 0) {
while (count($repositories['repositories']) < $repositories['total_count']) {
$page++;
$repositories = loadRepositoryByPage($githubApp, $token, $page);
}
}
$gitRepository = $request->git_repository;
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
}
$gitRepositoryFound = collect($repositories['repositories'])->firstWhere('full_name', $gitRepository);
if (! $gitRepositoryFound) {
return response()->json(['message' => 'Repository not found.'], 404);
$gitRepository = str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
// Use direct API call to verify repository access instead of loading all repositories
// This is much faster and avoids timeouts for GitHub Apps with many repositories
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$gitRepository}");
if ($response->status() === 404 || $response->status() === 403) {
return response()->json(['message' => 'Repository not found or not accessible by the GitHub App.'], 404);
}
if (! $response->successful()) {
return response()->json(['message' => 'Failed to verify repository access: '.($response->json()['message'] ?? 'Unknown error')], 400);
}
$gitRepositoryFound = $response->json();
$repository_project_id = data_get($gitRepositoryFound, 'id');
$application = new Application;
@@ -1319,39 +1377,63 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
$domainErrors = [];
foreach ($dockerComposeDomains as $index => $item) {
// Collect all URLs from all docker_compose_domains items
$urls = $dockerComposeDomains->flatMap(function ($item) {
$domainValue = data_get($item, 'domain');
if (filled($domainValue)) {
$urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim();
str($urls)->explode(',')->each(function ($url) use (&$domainErrors) {
$url = trim($url);
if (empty($url)) {
return;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$domainErrors[] = "Invalid URL: {$url}";
return;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
});
if (blank($domainValue)) {
return [];
}
return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed. ';
}
if (! empty($domainErrors)) {
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_domains' => $domainErrors,
],
'errors' => ['docker_compose_domains' => $errors],
], 422);
}
// Check for domain conflicts
if ($urls->isNotEmpty()) {
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => $result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
@@ -1376,6 +1458,26 @@ class ApplicationsController extends Controller
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($isStatic)) {
$application->settings->is_static = $isStatic;
$application->settings->save();
}
if (isset($isSpa)) {
$application->settings->is_spa = $isSpa;
$application->settings->save();
}
if (isset($isAutoDeployEnabled)) {
$application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled;
$application->settings->save();
}
if (isset($isForceHttpsEnabled)) {
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1476,39 +1578,63 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
$domainErrors = [];
foreach ($dockerComposeDomains as $index => $item) {
// Collect all URLs from all docker_compose_domains items
$urls = $dockerComposeDomains->flatMap(function ($item) {
$domainValue = data_get($item, 'domain');
if (filled($domainValue)) {
$urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim();
str($urls)->explode(',')->each(function ($url) use (&$domainErrors) {
$url = trim($url);
if (empty($url)) {
return;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$domainErrors[] = "Invalid URL: {$url}";
return;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
});
if (blank($domainValue)) {
return [];
}
return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.';
}
if (! empty($domainErrors)) {
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_domains' => $domainErrors,
],
'errors' => ['docker_compose_domains' => $errors],
], 422);
}
// Check for domain conflicts
if ($urls->isNotEmpty()) {
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => $result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
@@ -1529,6 +1655,26 @@ class ApplicationsController extends Controller
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($isStatic)) {
$application->settings->is_static = $isStatic;
$application->settings->save();
}
if (isset($isSpa)) {
$application->settings->is_spa = $isSpa;
$application->settings->save();
}
if (isset($isAutoDeployEnabled)) {
$application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled;
$application->settings->save();
}
if (isset($isForceHttpsEnabled)) {
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1632,6 +1778,14 @@ class ApplicationsController extends Controller
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($isForceHttpsEnabled)) {
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1734,6 +1888,14 @@ class ApplicationsController extends Controller
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($isForceHttpsEnabled)) {
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -2150,11 +2312,14 @@ class ApplicationsController extends Controller
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application domains.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'],
'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'],
'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'],
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
'start_command' => ['type' => 'string', 'description' => 'The start command.'],
@@ -2193,6 +2358,7 @@ class ApplicationsController extends Controller
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'],
'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'],
@@ -2297,7 +2463,7 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings','custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -2411,18 +2577,30 @@ class ApplicationsController extends Controller
$requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$fqdn = $request->domains;
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$urls = $request->domains;
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
$domain = trim($domain);
if (filter_var($domain, FILTER_VALIDATE_URL) === false || ! preg_match('/^https?:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}/', $domain)) {
$errors[] = 'Invalid domain: '.$domain;
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
return $domain;
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
@@ -2430,7 +2608,7 @@ class ApplicationsController extends Controller
], 422);
}
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid);
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
@@ -2460,39 +2638,63 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains = collect($request->docker_compose_domains);
$domainErrors = [];
foreach ($dockerComposeDomains as $item) {
// Collect all URLs from all docker_compose_domains items
$urls = $dockerComposeDomains->flatMap(function ($item) {
$domainValue = data_get($item, 'domain');
if (filled($domainValue)) {
$urls = str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim();
str($urls)->explode(',')->each(function ($url) use (&$domainErrors) {
$url = trim($url);
if (empty($url)) {
return;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$domainErrors[] = "Invalid URL: {$url}";
return;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$domainErrors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
});
if (blank($domainValue)) {
return [];
}
return str($domainValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $request->boolean('force_domain_override')) {
$errors[] = 'The current request contains conflicting URLs: '.implode(', ', $duplicates->toArray()).' Use force_domain_override=true to proceed.';
}
if (! empty($domainErrors)) {
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_domains' => $domainErrors,
],
'errors' => ['docker_compose_domains' => $errors],
], 422);
}
// Check for domain conflicts
if ($urls->isNotEmpty()) {
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $request->uuid);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => $result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$yaml = Yaml::parse($application->docker_compose_raw);
$services = data_get($yaml, 'services', []);
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) {
@@ -2505,6 +2707,9 @@ class ApplicationsController extends Controller
}
$instantDeploy = $request->instant_deploy;
$isStatic = $request->is_static;
$isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled;
$isForceHttpsEnabled = $request->is_force_https_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
@@ -2519,6 +2724,21 @@ class ApplicationsController extends Controller
$application->settings->save();
}
if (isset($isSpa)) {
$application->settings->is_spa = $isSpa;
$application->settings->save();
}
if (isset($isAutoDeployEnabled)) {
$application->settings->is_auto_deploy_enabled = $isAutoDeployEnabled;
$application->settings->save();
}
if (isset($isForceHttpsEnabled)) {
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -2699,10 +2919,7 @@ class ApplicationsController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
]
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -3626,17 +3843,29 @@ class ApplicationsController extends Controller
}
if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$fqdn = $request->domains;
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$urls = $request->domains;
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
$domain = trim($domain);
if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
$errors[] = 'Invalid domain: '.$domain;
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
return str($domain)->lower();
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
if (count($errors) > 0) {
return response()->json([
@@ -3645,7 +3874,7 @@ class ApplicationsController extends Controller
], 422);
}
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid);
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
@@ -0,0 +1,922 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ScheduledTasksController extends Controller
{
private function removeSensitiveData($task)
{
$task->makeHidden([
'id',
'team_id',
'application_id',
'service_id',
]);
return serializeApiResponse($task);
}
private function resolveApplication(Request $request, int $teamId): ?Application
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
}
private function resolveService(Request $request, int $teamId): ?Service
{
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$tasks = $resource->scheduled_tasks->map(function ($task) {
return $this->removeSensitiveData($task);
});
return response()->json($tasks);
}
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if (! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$teamId = getTeamIdFromToken();
$task = new ScheduledTask;
$task->name = $request->name;
$task->command = $request->command;
$task->frequency = $request->frequency;
$task->container = $request->container;
$task->timeout = $request->has('timeout') ? $request->timeout : 300;
$task->enabled = $request->has('enabled') ? $request->enabled : true;
$task->team_id = $teamId;
if ($resource instanceof Application) {
$task->application_id = $resource->id;
} elseif ($resource instanceof Service) {
$task->service_id = $resource->id;
}
$task->save();
return response()->json($this->removeSensitiveData($task), 201);
}
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
if ($request->all() === []) {
return response()->json(['message' => 'At least one field must be provided.'], 422);
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
'command' => 'string',
'frequency' => 'string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$task->update($request->only($allowedFields));
return response()->json($this->removeSensitiveData($task), 200);
}
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
if (! $deleted) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
return response()->json(['message' => 'Scheduled task deleted.']);
}
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$executions = $task->executions()->get()->map(function ($execution) {
$execution->makeHidden(['id', 'scheduled_task_id']);
return serializeApiResponse($execution);
});
return response()->json($executions);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for an application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->listTasks($application);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->createTask($request, $application);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->updateTask($request, $application);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->deleteTask($request, $application);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->getExecutions($request, $application);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for a service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->listTasks($service);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->createTask($request, $service);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->updateTask($request, $service);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->deleteTask($request, $service);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->getExecutions($request, $service);
}
}
@@ -519,9 +519,13 @@ class ServersController extends Controller
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$allServers = ModelsServer::whereIp($request->ip)->get();
if ($allServers->count() > 0) {
return response()->json(['message' => 'Server with this IP already exists.'], 400);
$foundServer = ModelsServer::whereIp($request->ip)->first();
if ($foundServer) {
if ($foundServer->team_id === $teamId) {
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
}
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
}
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
@@ -1141,10 +1141,7 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
]
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -1265,10 +1262,8 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variables updated.'],
]
type: 'array',
items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
)
),
]
+10 -10
View File
@@ -1208,7 +1208,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
}
}
@@ -1265,8 +1265,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
$envs->push('SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyFqdn);
}
}
@@ -1274,7 +1274,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
$envs->push('SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
}
@@ -1476,7 +1476,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs_dict['SERVICE_NAME_'.str($serviceName)->upper()] = escapeBashEnvValue($serviceName);
$envs_dict['SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($serviceName);
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@@ -1489,8 +1489,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
$envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn);
}
}
} else {
@@ -1498,7 +1498,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@@ -1511,8 +1511,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
$envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn);
}
}
}
+24 -1
View File
@@ -47,7 +47,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
match ($this->status) {
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".$this->getPreviewLinks(),
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
@@ -91,4 +91,27 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
{
githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete');
}
private function getPreviewLinks(): string
{
if ($this->application->build_pack === 'dockercompose') {
$dockerComposeDomains = json_decode($this->preview->docker_compose_domains, true) ?? [];
$links = [];
foreach ($dockerComposeDomains as $serviceName => $config) {
$domain = data_get($config, 'domain');
if (! empty($domain)) {
$firstDomain = str($domain)->explode(',')->first();
$firstDomain = trim($firstDomain);
if (! empty($firstDomain)) {
$links[] = "[Open {$serviceName}]({$firstDomain})";
}
}
}
return ! empty($links) ? implode(' | ', $links).' | ' : '';
}
return $this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : '';
}
}
+11 -1
View File
@@ -207,6 +207,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if (empty($subId)) {
continue;
}
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation
@@ -496,7 +499,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if (! $tcpProxyContainerFound) {
StartDatabaseProxy::dispatch($database);
$this->server->team?->notify(new ContainerRestarted("TCP Proxy for {$database->name}", $this->server));
} else {
}
} elseif ($this->isRunning($containerStatus) && ! $tcpProxy) {
// Clean up orphaned proxy containers when is_public=false
$orphanedProxy = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
})->first();
if ($orphanedProxy) {
StopDatabaseProxy::dispatch($database);
}
}
}
+93 -22
View File
@@ -27,6 +27,10 @@ class ScheduledJobManager implements ShouldQueue
*/
private ?Carbon $executionTime = null;
private int $dispatchedCount = 0;
private int $skippedCount = 0;
/**
* Create a new job instance.
*/
@@ -61,6 +65,12 @@ class ScheduledJobManager implements ShouldQueue
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
$this->dispatchedCount = 0;
$this->skippedCount = 0;
Log::channel('scheduled')->info('ScheduledJobManager started', [
'execution_time' => $this->executionTime->toIso8601String(),
]);
// Process backups - don't let failures stop task processing
try {
@@ -91,6 +101,13 @@ class ScheduledJobManager implements ShouldQueue
'trace' => $e->getTraceAsString(),
]);
}
Log::channel('scheduled')->info('ScheduledJobManager completed', [
'execution_time' => $this->executionTime->toIso8601String(),
'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime),
'dispatched' => $this->dispatchedCount,
'skipped' => $this->skippedCount,
]);
}
private function processScheduledBackups(): void
@@ -101,8 +118,16 @@ class ScheduledJobManager implements ShouldQueue
foreach ($backups as $backup) {
try {
// Apply the same filtering logic as the original
if (! $this->shouldProcessBackup($backup)) {
$skipReason = $this->getBackupSkipReason($backup);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('backup', $skipReason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
continue;
}
@@ -120,6 +145,14 @@ class ScheduledJobManager implements ShouldQueue
if ($this->shouldRunNow($frequency, $serverTimezone)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
@@ -138,7 +171,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($tasks as $task) {
try {
if (! $this->shouldProcessTask($task)) {
$skipReason = $this->getTaskSkipReason($task);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('task', $skipReason, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $task->server()?->team_id,
]);
continue;
}
@@ -156,6 +197,13 @@ class ScheduledJobManager implements ShouldQueue
if ($this->shouldRunNow($frequency, $serverTimezone)) {
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
@@ -166,33 +214,33 @@ class ScheduledJobManager implements ShouldQueue
}
}
private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string
{
if (blank(data_get($backup, 'database'))) {
$backup->delete();
return false;
return 'database_deleted';
}
$server = $backup->server();
if (blank($server)) {
$backup->delete();
return false;
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return false;
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
return 'subscription_unpaid';
}
return true;
return null;
}
private function shouldProcessTask(ScheduledTask $task): bool
private function getTaskSkipReason(ScheduledTask $task): ?string
{
$service = $task->service;
$application = $task->application;
@@ -201,32 +249,32 @@ class ScheduledJobManager implements ShouldQueue
if (blank($server)) {
$task->delete();
return false;
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return false;
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
return 'subscription_unpaid';
}
if (! $service && ! $application) {
$task->delete();
return false;
return 'resource_deleted';
}
if ($application && str($application->status)->contains('running') === false) {
return false;
return 'application_not_running';
}
if ($service && str($service->status)->contains('running') === false) {
return false;
return 'service_not_running';
}
return true;
return null;
}
private function shouldRunNow(string $frequency, string $timezone): bool
@@ -248,7 +296,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($servers as $server) {
try {
if (! $this->shouldProcessDockerCleanup($server)) {
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
continue;
}
@@ -270,6 +326,12 @@ class ScheduledJobManager implements ShouldQueue
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
@@ -296,19 +358,28 @@ class ScheduledJobManager implements ShouldQueue
return $query->get();
}
private function shouldProcessDockerCleanup(Server $server): bool
private function getDockerCleanupSkipReason(Server $server): ?string
{
if (! $server->isFunctional()) {
return false;
return 'server_not_functional';
}
// In cloud, check subscription status (except team 0)
if (isCloud() && $server->team_id !== 0) {
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
return false;
return 'subscription_unpaid';
}
}
return true;
return null;
}
private function logSkip(string $type, string $reason, array $context = []): void
{
Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([
'type' => $type,
'skip_reason' => $reason,
'execution_time' => $this->executionTime?->toIso8601String(),
], $context));
}
}
+50
View File
@@ -22,6 +22,36 @@ class SendMessageToSlackJob implements ShouldQueue
}
public function handle(): void
{
if ($this->isSlackWebhook()) {
$this->sendToSlack();
return;
}
/**
* This works with Mattermost and as a fallback also with Slack, the notifications just look slightly different and advanced formatting for slack is not supported with Mattermost.
*
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/
$this->sendToMattermost();
}
private function isSlackWebhook(): bool
{
$parsedUrl = parse_url($this->webhookUrl);
if ($parsedUrl === false) {
return false;
}
$scheme = $parsedUrl['scheme'] ?? '';
$host = $parsedUrl['host'] ?? '';
return $scheme === 'https' && $host === 'hooks.slack.com';
}
private function sendToSlack(): void
{
Http::post($this->webhookUrl, [
'text' => $this->message->title,
@@ -57,4 +87,24 @@ class SendMessageToSlackJob implements ShouldQueue
],
]);
}
/**
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
*/
private function sendToMattermost(): void
{
$username = config('app.name');
Http::post($this->webhookUrl, [
'username' => $username,
'attachments' => [
[
'title' => $this->message->title,
'color' => $this->message->color,
'text' => $this->message->description,
'footer' => $username,
],
],
]);
}
}
+14
View File
@@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public Server $server) {}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
public function handle()
{
try {
+20 -1
View File
@@ -101,12 +101,31 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_usable' => false,
]);
throw $e;
return;
}
}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerConnectionCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
private function checkHetznerStatus(): void
{
$status = null;
try {
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
+14
View File
@@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
@@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function __construct(public Server $server, public int|string|null $percentage = null) {}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerStorageCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
public function handle()
{
try {
+5 -1
View File
@@ -283,7 +283,11 @@ class Index extends Component
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
if ($foundServer) {
return $this->dispatch('error', 'IP address is already in use by another team.');
if ($foundServer->team_id === currentTeam()->id) {
return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
}
return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
}
$this->createdServer = Server::create([
'name' => $this->remoteServerName,
+27 -12
View File
@@ -257,7 +257,7 @@ class GlobalSearch extends Component
// Get all applications
$applications = Application::ownedByCurrentTeam()
->with(['environment.project'])
->with(['environment.project', 'previews:id,application_id,pull_request_id'])
->get()
->map(function ($app) {
// Collect all FQDNs from the application
@@ -286,6 +286,16 @@ class GlobalSearch extends Component
$fqdnsString = $fqdns->implode(' ');
// Add PR search terms if preview is enabled
$prSearchTerms = '';
if ($app->preview_enabled ?? false) {
$prIds = collect($app->previews ?? [])
->pluck('pull_request_id')
->map(fn ($id) => "pr-{$id} pr{$id} {$id}")
->implode(' ');
$prSearchTerms = $prIds;
}
return [
'id' => $app->id,
'name' => $app->name,
@@ -296,13 +306,13 @@ class GlobalSearch extends Component
'project' => $app->environment->project->name ?? null,
'environment' => $app->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' application applications app apps'),
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'),
];
});
// Get all services
$services = Service::ownedByCurrentTeam()
->with(['environment.project', 'applications'])
->with(['environment.project', 'applications', 'databases'])
->get()
->map(function ($service) {
// Collect all FQDNs from service applications
@@ -315,6 +325,10 @@ class GlobalSearch extends Component
}
$fqdnsString = $fqdns->implode(' ');
// Collect service component names for container search
$serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' ');
$serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' ');
return [
'id' => $service->id,
'name' => $service->name,
@@ -325,7 +339,7 @@ class GlobalSearch extends Component
'project' => $service->environment->project->name ?? null,
'environment' => $service->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' service services'),
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'),
];
});
@@ -348,7 +362,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' postgresql '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'),
];
})
);
@@ -369,7 +383,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mysql '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'),
];
})
);
@@ -390,7 +404,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mariadb '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'),
];
})
);
@@ -411,7 +425,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mongodb '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'),
];
})
);
@@ -432,7 +446,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' redis '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'),
];
})
);
@@ -453,7 +467,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' keydb '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'),
];
})
);
@@ -474,7 +488,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' dragonfly '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'),
];
})
);
@@ -495,7 +509,7 @@ class GlobalSearch extends Component
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' clickhouse '.$db->description.' database databases db'),
'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'),
];
})
);
@@ -1481,6 +1495,7 @@ class GlobalSearch extends Component
'type' => 'one-click-service-'.$serviceKey,
'category' => 'Services',
'resourceType' => 'service',
'logo' => data_get($service, 'logo'),
]);
}
+5 -5
View File
@@ -147,7 +147,7 @@ class Import extends Component
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@@ -261,11 +261,11 @@ EOD;
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
@@ -757,7 +757,7 @@ EOD;
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:\${POSTGRES_USER:-postgres}}";
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$tmpPath}";
}
@@ -75,6 +75,11 @@ class GithubPrivateRepository extends Component
$this->github_apps = GithubApp::private();
}
public function updatedSelectedRepositoryId(): void
{
$this->loadBranches();
}
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
+44 -11
View File
@@ -33,6 +33,10 @@ class Index extends Component
public Collection $services;
public Collection $allProjects;
public Collection $allEnvironments;
public array $parameters;
public function mount()
@@ -50,6 +54,33 @@ class Index extends Component
->firstOrFail();
$this->project = $project;
// Load projects and environments for breadcrumb navigation (avoids inline queries in view)
$this->allProjects = Project::ownedByCurrentTeamCached();
$this->allEnvironments = $project->environments()
->with([
'applications.additional_servers',
'applications.destination.server',
'services',
'services.destination.server',
'postgresqls',
'postgresqls.destination.server',
'redis',
'redis.destination.server',
'mongodbs',
'mongodbs.destination.server',
'mysqls',
'mysqls.destination.server',
'mariadbs',
'mariadbs.destination.server',
'keydbs',
'keydbs.destination.server',
'dragonflies',
'dragonflies.destination.server',
'clickhouses',
'clickhouses.destination.server',
])->get();
$this->environment = $environment->loadCount([
'applications',
'redis',
@@ -71,11 +102,13 @@ class Index extends Component
'destination.server.settings',
'settings',
])->get()->sortBy('name');
$this->applications = $this->applications->map(function ($application) {
$projectUuid = $this->project->uuid;
$environmentUuid = $this->environment->uuid;
$this->applications = $this->applications->map(function ($application) use ($projectUuid, $environmentUuid) {
$application->hrefLink = route('project.application.configuration', [
'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => data_get($application, 'uuid'),
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
'application_uuid' => $application->uuid,
]);
return $application;
@@ -98,11 +131,11 @@ class Index extends Component
'tags',
'destination.server.settings',
])->get()->sortBy('name');
$this->{$property} = $this->{$property}->map(function ($db) {
$this->{$property} = $this->{$property}->map(function ($db) use ($projectUuid, $environmentUuid) {
$db->hrefLink = route('project.database.configuration', [
'project_uuid' => $this->project->uuid,
'project_uuid' => $projectUuid,
'database_uuid' => $db->uuid,
'environment_uuid' => data_get($this->environment, 'uuid'),
'environment_uuid' => $environmentUuid,
]);
return $db;
@@ -114,11 +147,11 @@ class Index extends Component
'tags',
'destination.server.settings',
])->get()->sortBy('name');
$this->services = $this->services->map(function ($service) {
$this->services = $this->services->map(function ($service) use ($projectUuid, $environmentUuid) {
$service->hrefLink = route('project.service.configuration', [
'project_uuid' => data_get($service, 'environment.project.uuid'),
'environment_uuid' => data_get($service, 'environment.uuid'),
'service_uuid' => data_get($service, 'uuid'),
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
'service_uuid' => $service->uuid,
]);
return $service;
+7 -4
View File
@@ -97,10 +97,13 @@ class ByIp extends Component
$this->validate();
try {
$this->authorize('create', Server::class);
if (Server::where('team_id', currentTeam()->id)
->where('ip', $this->ip)
->exists()) {
return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.');
$foundServer = Server::whereIp($this->ip)->first();
if ($foundServer) {
if ($foundServer->team_id === currentTeam()->id) {
return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
}
return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
}
if (is_null($this->private_key_id)) {
+8 -4
View File
@@ -189,12 +189,16 @@ class Show extends Component
$this->validate();
$this->authorize('update', $this->server);
if (Server::where('team_id', currentTeam()->id)
->where('ip', $this->ip)
$foundServer = Server::where('ip', $this->ip)
->where('id', '!=', $this->server->id)
->exists()) {
->first();
if ($foundServer) {
$this->ip = $this->server->ip;
throw new \Exception('This IP/Domain is already in use by another server in your team.');
if ($foundServer->team_id === currentTeam()->id) {
throw new \Exception('A server with this IP/Domain already exists in your team.');
}
throw new \Exception('A server with this IP/Domain is already in use by another team.');
}
$this->server->name = $this->name;
+6 -1
View File
@@ -14,7 +14,7 @@ class Index extends Component
public ?Server $server = null;
#[Validate('nullable|string|max:255')]
#[Validate('nullable|string|max:255|url')]
public ?string $fqdn = null;
#[Validate('required|integer|min:1025|max:65535')]
@@ -46,6 +46,11 @@ class Index extends Component
public $buildActivityId = null;
protected array $messages = [
'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.',
];
public function render()
{
return view('livewire.settings.index');
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Livewire\Settings;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use App\Services\SchedulerLogParser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Component;
class ScheduledJobs extends Component
{
public string $filterType = 'all';
public string $filterDate = 'last_24h';
protected Collection $executions;
protected Collection $skipLogs;
protected Collection $managerRuns;
public function boot(): void
{
$this->executions = collect();
$this->skipLogs = collect();
$this->managerRuns = collect();
}
public function mount(): void
{
if (! isInstanceAdmin()) {
redirect()->route('dashboard');
return;
}
$this->loadData();
}
public function updatedFilterType(): void
{
$this->loadData();
}
public function updatedFilterDate(): void
{
$this->loadData();
}
public function refresh(): void
{
$this->loadData();
}
public function render()
{
return view('livewire.settings.scheduled-jobs', [
'executions' => $this->executions,
'skipLogs' => $this->skipLogs,
'managerRuns' => $this->managerRuns,
]);
}
private function loadData(?int $teamId = null): void
{
$this->executions = $this->getExecutions($teamId);
$parser = new SchedulerLogParser;
$this->skipLogs = $parser->getRecentSkips(50, $teamId);
$this->managerRuns = $parser->getRecentRuns(30, $teamId);
}
private function getExecutions(?int $teamId = null): Collection
{
$dateFrom = $this->getDateFrom();
$backups = collect();
$tasks = collect();
$cleanups = collect();
if ($this->filterType === 'all' || $this->filterType === 'backup') {
$backups = $this->getBackupExecutions($dateFrom, $teamId);
}
if ($this->filterType === 'all' || $this->filterType === 'task') {
$tasks = $this->getTaskExecutions($dateFrom, $teamId);
}
if ($this->filterType === 'all' || $this->filterType === 'cleanup') {
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
}
return $backups->concat($tasks)->concat($cleanups)
->sortByDesc('created_at')
->values()
->take(100);
}
private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
{
$query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team'])
->where('status', 'failed')
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId))
->orderBy('created_at', 'desc')
->limit(100)
->get();
return $query->map(function ($execution) {
$backup = $execution->scheduledDatabaseBackup;
$database = $backup?->database;
$server = $backup?->server();
return [
'id' => $execution->id,
'type' => 'backup',
'status' => $execution->status ?? 'unknown',
'resource_name' => $database?->name ?? 'Deleted database',
'resource_type' => $database ? class_basename($database) : null,
'server_name' => $server?->name ?? 'Unknown',
'server_id' => $server?->id,
'team_id' => $backup?->team_id,
'created_at' => $execution->created_at,
'finished_at' => $execution->updated_at,
'message' => $execution->message,
'size' => $execution->size ?? null,
];
});
}
private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection
{
$query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service'])
->where('status', 'failed')
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
->when($teamId, function ($q) use ($teamId) {
$q->where(function ($sub) use ($teamId) {
$sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId)
->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId);
});
})
->orderBy('created_at', 'desc')
->limit(100)
->get();
return $query->map(function ($execution) {
$task = $execution->scheduledTask;
$resource = $task?->application ?? $task?->service;
$server = $task?->server();
$teamId = $server?->team_id;
return [
'id' => $execution->id,
'type' => 'task',
'status' => $execution->status ?? 'unknown',
'resource_name' => $task?->name ?? 'Deleted task',
'resource_type' => $resource ? class_basename($resource) : null,
'server_name' => $server?->name ?? 'Unknown',
'server_id' => $server?->id,
'team_id' => $teamId,
'created_at' => $execution->created_at,
'finished_at' => $execution->finished_at,
'message' => $execution->message,
'size' => null,
];
});
}
private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
{
$query = DockerCleanupExecution::with(['server'])
->where('status', 'failed')
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId))
->orderBy('created_at', 'desc')
->limit(100)
->get();
return $query->map(function ($execution) {
$server = $execution->server;
return [
'id' => $execution->id,
'type' => 'cleanup',
'status' => $execution->status ?? 'unknown',
'resource_name' => $server?->name ?? 'Deleted server',
'resource_type' => 'Server',
'server_name' => $server?->name ?? 'Unknown',
'server_id' => $server?->id,
'team_id' => $server?->team_id,
'created_at' => $execution->created_at,
'finished_at' => $execution->finished_at ?? $execution->updated_at,
'message' => $execution->message,
'size' => null,
];
});
}
private function getDateFrom(): ?Carbon
{
return match ($this->filterDate) {
'last_24h' => now()->subDay(),
'last_7d' => now()->subWeek(),
'last_30d' => now()->subMonth(),
default => null,
};
}
}
+6
View File
@@ -123,6 +123,12 @@ class EnvironmentVariable extends BaseModel
}
$real_value = $this->get_real_environment_variables($this->value, $resource);
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) {
return $real_value;
}
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
+5 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Once;
use Spatie\Url\Url;
class InstanceSettings extends Model
@@ -35,6 +36,9 @@ class InstanceSettings extends Model
protected static function booted(): void
{
static::updated(function ($settings) {
// Clear once() cache so subsequent calls get fresh data
Once::flush();
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');
@@ -82,7 +86,7 @@ class InstanceSettings extends Model
public static function get()
{
return InstanceSettings::findOrFail(0);
return once(fn () => InstanceSettings::findOrFail(0));
}
// public function getRecipients($notification)
+22
View File
@@ -5,13 +5,35 @@ namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Scheduled Task model',
type: 'object',
properties: [
'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'],
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'],
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'],
],
)]
class ScheduledTask extends BaseModel
{
use HasSafeStringAttribute;
protected $guarded = [];
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::where('team_id', $teamId)->orderBy('created_at', 'desc');
}
protected function casts(): array
{
return [
+16
View File
@@ -3,7 +3,23 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Scheduled Task Execution model',
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'],
'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'],
'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'],
'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'],
'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'],
'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'],
'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'],
],
)]
class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];
+40
View File
@@ -108,6 +108,12 @@ class Server extends BaseModel
public static $batch_counter = 0;
/**
* Identity map cache for request-scoped Server lookups.
* Prevents N+1 queries when the same Server is accessed multiple times.
*/
private static ?array $identityMapCache = null;
protected $appends = ['is_coolify_host'];
protected static function booted()
@@ -186,6 +192,40 @@ class Server extends BaseModel
$server->settings()->delete();
$server->sslCertificates()->delete();
});
static::updated(function () {
static::flushIdentityMap();
});
}
/**
* Find a Server by ID using the identity map cache.
* This prevents N+1 queries when the same Server is accessed multiple times.
*/
public static function findCached($id): ?static
{
if ($id === null) {
return null;
}
if (static::$identityMapCache === null) {
static::$identityMapCache = [];
}
if (! isset(static::$identityMapCache[$id])) {
static::$identityMapCache[$id] = static::query()->find($id);
}
return static::$identityMapCache[$id];
}
/**
* Flush the identity map cache.
* Called automatically on update, and should be called in tests.
*/
public static function flushIdentityMap(): void
{
static::$identityMapCache = null;
}
protected $casts = [
+35 -1
View File
@@ -881,7 +881,7 @@ class Service extends BaseModel
],
]);
}
if ($SERVICE_PASSWORD_LINKDING) {
if ($SERVICE_PASSWORD_LINKDING) {
$data = $data->merge([
'Superuser Password' => [
'key' => data_get($SERVICE_PASSWORD_LINKDING, 'key'),
@@ -1118,6 +1118,40 @@ class Service extends BaseModel
}
$fields->put('Sessy', $data->toArray());
break;
case $image->contains('coollabsio/openclaw'):
$data = collect([]);
$username = $this->environment_variables()->where('key', 'AUTH_USERNAME')->first();
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_OPENCLAW')->first();
$gateway_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_64_GATEWAYTOKEN')->first();
if ($username) {
$data = $data->merge([
'Username' => [
'key' => data_get($username, 'key'),
'value' => data_get($username, 'value'),
'readonly' => true,
],
]);
}
if ($password) {
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'isPassword' => true,
],
]);
}
if ($gateway_token) {
$data = $data->merge([
'Gateway Token' => [
'key' => data_get($gateway_token, 'key'),
'value' => data_get($gateway_token, 'value'),
'isPassword' => true,
],
]);
}
$fields->put('Openclaw', $data->toArray());
break;
default:
$data = collect([]);
$admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first();
+22
View File
@@ -73,6 +73,28 @@ class StandaloneDocker extends BaseModel
return $this->belongsTo(Server::class);
}
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
*/
public function getServerAttribute(): ?Server
{
// Use eager loaded data if available
if ($this->relationLoaded('server')) {
return $this->getRelation('server');
}
// Use identity map for lazy loading
$server = Server::findCached($this->server_id);
// Cache in relation for future access on this instance
if ($server) {
$this->setRelation('server', $server);
}
return $server;
}
public function services()
{
return $this->morphMany(Service::class, 'destination');
+15 -1
View File
@@ -28,9 +28,23 @@ class StandalonePostgresql extends BaseModel
protected static function booted()
{
static::created(function ($database) {
// This is really stupid and it took me 1h to figure out why the image was not loading properly. This is exactly the reason why we need to use the action pattern because Model events and Accessors are a fragile mess!
$image = (string) ($database->getAttributes()['image'] ?? '');
$majorVersion = 0;
if (preg_match('/:(?:pg)?(\d+)/i', $image, $matches)) {
$majorVersion = (int) $matches[1];
}
// PostgreSQL 18+ uses /var/lib/postgresql as mount path
// Older versions use /var/lib/postgresql/data
$mountPath = $majorVersion >= 18
? '/var/lib/postgresql'
: '/var/lib/postgresql/data';
LocalPersistentVolume::create([
'name' => 'postgres-data-'.$database->uuid,
'mount_path' => '/var/lib/postgresql/data',
'mount_path' => $mountPath,
'host_path' => null,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
+22
View File
@@ -56,6 +56,28 @@ class SwarmDocker extends BaseModel
return $this->belongsTo(Server::class);
}
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
*/
public function getServerAttribute(): ?Server
{
// Use eager loaded data if available
if ($this->relationLoaded('server')) {
return $this->getRelation('server');
}
// Use identity map for lazy loading
$server = Server::findCached($this->server_id);
// Cache in relation for future access on this instance
if ($server) {
$this->setRelation('server', $server);
}
return $server;
}
public function services()
{
return $this->morphMany(Service::class, 'destination');
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
class SchedulerLogParser
{
/**
* Get recent skip events from the scheduled log files.
*
* @return Collection<int, array{timestamp: string, type: string, reason: string, team_id: ?int, context: array}>
*/
public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection
{
$logFiles = $this->getLogFiles();
$skips = collect();
foreach ($logFiles as $logFile) {
$lines = $this->readLastLines($logFile, 2000);
foreach ($lines as $line) {
$entry = $this->parseLogLine($line);
if ($entry === null || ! isset($entry['context']['skip_reason'])) {
continue;
}
if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) {
continue;
}
$skips->push([
'timestamp' => $entry['timestamp'],
'type' => $entry['context']['type'] ?? 'unknown',
'reason' => $entry['context']['skip_reason'],
'team_id' => $entry['context']['team_id'] ?? null,
'context' => $entry['context'],
]);
}
}
return $skips->sortByDesc('timestamp')->values()->take($limit);
}
/**
* Get recent manager execution logs (start/complete events).
*
* @return Collection<int, array{timestamp: string, message: string, duration_ms: ?int, dispatched: ?int, skipped: ?int}>
*/
public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection
{
$logFiles = $this->getLogFiles();
$runs = collect();
foreach ($logFiles as $logFile) {
$lines = $this->readLastLines($logFile, 2000);
foreach ($lines as $line) {
$entry = $this->parseLogLine($line);
if ($entry === null) {
continue;
}
if (! str_contains($entry['message'], 'ScheduledJobManager')) {
continue;
}
$runs->push([
'timestamp' => $entry['timestamp'],
'message' => $entry['message'],
'duration_ms' => $entry['context']['duration_ms'] ?? null,
'dispatched' => $entry['context']['dispatched'] ?? null,
'skipped' => $entry['context']['skipped'] ?? null,
]);
}
}
return $runs->sortByDesc('timestamp')->values()->take($limit);
}
private function getLogFiles(): array
{
$logDir = storage_path('logs');
if (! File::isDirectory($logDir)) {
return [];
}
$files = File::glob($logDir.'/scheduled-*.log');
// Sort by modification time, newest first
usort($files, fn ($a, $b) => filemtime($b) - filemtime($a));
// Only check last 3 days of logs
return array_slice($files, 0, 3);
}
/**
* @return array{timestamp: string, level: string, message: string, context: array}|null
*/
private function parseLogLine(string $line): ?array
{
// Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"}
if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) {
return null;
}
$timestamp = $matches[1];
$level = $matches[2];
$rest = $matches[3];
// Extract JSON context if present
$context = [];
if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) {
$message = $contextMatches[1];
$decoded = json_decode($contextMatches[2], true);
if (is_array($decoded)) {
$context = $decoded;
}
} else {
$message = $rest;
}
return [
'timestamp' => $timestamp,
'level' => $level,
'message' => $message,
'context' => $context,
];
}
/**
* Efficiently read the last N lines of a file.
*
* @return string[]
*/
private function readLastLines(string $filePath, int $lines): array
{
if (! File::exists($filePath)) {
return [];
}
$fileSize = File::size($filePath);
if ($fileSize === 0) {
return [];
}
// For small files, read the whole thing
if ($fileSize < 1024 * 1024) {
$content = File::get($filePath);
return array_filter(explode("\n", $content), fn ($line) => $line !== '');
}
// For large files, read from the end
$handle = fopen($filePath, 'r');
if ($handle === false) {
return [];
}
$result = [];
$chunkSize = 8192;
$buffer = '';
$position = $fileSize;
while ($position > 0 && count($result) < $lines) {
$readSize = min($chunkSize, $position);
$position -= $readSize;
fseek($handle, $position);
$buffer = fread($handle, $readSize).$buffer;
$bufferLines = explode("\n", $buffer);
$buffer = array_shift($bufferLines);
$result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result);
}
if ($buffer !== '' && count($result) < $lines) {
array_unshift($result, $buffer);
}
fclose($handle);
return array_slice($result, -$lines);
}
}
+4 -4
View File
@@ -10,12 +10,12 @@ class ValidationPatterns
/**
* Pattern for names excluding all dangerous characters
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.]+$/u';
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
/**
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
*/
public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*]+$/u';
public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u';
/**
* Get validation rules for name fields
@@ -64,7 +64,7 @@ class ValidationPatterns
public static function nameMessages(): array
{
return [
'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, dashes (-), underscores (_) and dots (.).",
'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &",
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
@@ -76,7 +76,7 @@ class ValidationPatterns
public static function descriptionMessages(): array
{
return [
'description.regex' => "The description may only contain letters (including Unicode), numbers, spaces, and common punctuation (- _ . , ! ? ( ) ' \" + = *).",
'description.regex' => "The description may only contain letters (including Unicode), numbers, spaces, and common punctuation: - _ . , ! ? ( ) ' \" + = * / @ &",
'description.max' => 'The description may not be greater than :max characters.',
];
}
-41
View File
@@ -95,9 +95,6 @@ trait SshRetryable
if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
$delay = $this->calculateRetryDelay($attempt);
// Track SSH retry event in Sentry
$this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
// Add deployment log if available (for ExecuteRemoteCommand trait)
if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
$this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
@@ -133,42 +130,4 @@ trait SshRetryable
return null;
}
/**
* Track SSH retry event in Sentry
*/
protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
{
// Only track in production/cloud instances
if (isDev() || ! config('constants.sentry.sentry_dsn')) {
return;
}
try {
app('sentry')->captureMessage(
'SSH connection retry triggered',
\Sentry\Severity::warning(),
[
'extra' => [
'attempt' => $attempt,
'max_retries' => $maxRetries,
'delay_seconds' => $delay,
'error_message' => $errorMessage,
'context' => $context,
'retryable_error' => true,
],
'tags' => [
'component' => 'ssh_retry',
'error_type' => 'connection_retry',
],
]
);
} catch (\Throwable $e) {
// Don't let Sentry tracking errors break the SSH retry flow
Log::warning('Failed to track SSH retry event in Sentry', [
'error' => $e->getMessage(),
'original_attempt' => $attempt,
]);
}
}
}