Merge branch 'next' into shadow/add-shared-server-env

This commit is contained in:
ShadowArcanist authored and GitHub committed 2026-01-14 05:26:06 +01:00
commit e412b57df7
227 files changed
+9960 -15827

No files matched your search

@@ -55,6 +55,14 @@ class StopApplication
return $e->getMessage();
}
}
// Reset restart tracking when application is manually stopped
$application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
}
@@ -33,7 +33,6 @@ class StartDatabaseProxy
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$server = data_get($database, 'service.destination.server');
$proxyContainerName = "{$database->service->uuid}-proxy";
$containerName = "{$database->name}-{$database->service->uuid}";
}
$internalPort = match ($databaseType) {
+3 -5
View File
@@ -5,7 +5,6 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@@ -270,10 +269,9 @@ class StartKeydb
return;
}
$filename = 'keydb.conf';
Storage::disk('local')->put("tmp/keydb.conf_{$this->database->uuid}", $this->database->keydb_conf);
$path = Storage::path("tmp/keydb.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
$content = $this->database->keydb_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
private function buildStartCommand(): string
+3 -5
View File
@@ -5,7 +5,6 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@@ -316,9 +315,8 @@ class StartRedis
return;
}
$filename = 'redis.conf';
Storage::disk('local')->put("tmp/redis.conf_{$this->database->uuid}", $this->database->redis_conf);
$path = Storage::path("tmp/redis.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/redis.conf_{$this->database->uuid}");
$content = $this->database->redis_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}
+7
View File
@@ -28,6 +28,13 @@ class StopDatabase
$this->stopContainer($database, $database->uuid, 30);
// Reset restart tracking when database is manually stopped
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
}
@@ -145,6 +145,12 @@ class GetContainersStatus
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
// Fallback for Docker Swarm which uses different labels
if (! $containerName && $this->server->isSwarm()) {
$containerName = data_get($labels, 'coolify.serviceName')
?? data_get($labels, 'coolify.name')
?? data_get($labels, 'com.docker.stack.namespace');
}
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
+4 -4
View File
@@ -117,11 +117,11 @@ class InstallDocker
private function getDebianDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && '.
'chmod a+r /etc/apt/keyrings/docker.asc && '.
'. /etc/os-release && '.
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && '.
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '.
'chmod a+r /etc/apt/keyrings/docker.asc && '.
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && '.
'apt-get update && '.
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin'.
')';
+1 -1
View File
@@ -32,7 +32,7 @@ class CleanupNames extends Command
{--backup : Create database backup before changes}
{--force : Skip confirmation prompt}';
protected $description = 'Sanitize name fields by removing invalid characters (keeping only letters, numbers, spaces, dashes, underscores, dots, slashes, colons, parentheses)';
protected $description = 'Sanitize name fields by removing dangerous characters';
protected array $modelsToClean = [
'Project' => Project::class,
@@ -19,6 +19,7 @@ use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
@@ -64,6 +65,17 @@ class ApplicationsController extends Controller
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'tag',
in: 'query',
description: 'Filter applications by tag name.',
required: false,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
@@ -94,13 +106,19 @@ class ApplicationsController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$projects = Project::where('team_id', $teamId)->get();
$applications = collect();
$applications->push($projects->pluck('applications')->flatten());
$applications = $applications->flatten();
$applications = $applications->map(function ($application) {
return $this->removeSensitiveData($application);
});
$tagName = $request->query('tag');
$applications = Application::ownedByCurrentTeamAPI($teamId)
->when($tagName, function ($query, $tagName) {
$query->whereHas('tags', function ($query) use ($tagName) {
$query->where('name', $tagName);
});
})
->get()
->map(function ($application) {
return $this->removeSensitiveData($application);
});
return response()->json($applications);
}
@@ -181,10 +199,19 @@ class ApplicationsController extends Controller
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'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.'],
'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'],
'docker_compose_domains' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a dockercompose application.',
'items' => new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -193,6 +220,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -332,10 +360,19 @@ class ApplicationsController extends Controller
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'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.'],
'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'],
'docker_compose_domains' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a dockercompose application.',
'items' => new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -344,6 +381,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -483,10 +521,19 @@ class ApplicationsController extends Controller
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'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.'],
'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'],
'docker_compose_domains' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a dockercompose application.',
'items' => new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -495,6 +542,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -559,8 +607,8 @@ class ApplicationsController extends Controller
}
#[OA\Post(
summary: 'Create (Dockerfile)',
description: 'Create new application based on a simple Dockerfile.',
summary: 'Create (Dockerfile without git)',
description: 'Create new application based on a simple Dockerfile (without git).',
path: '/applications/dockerfile',
operationId: 'create-dockerfile-application',
security: [
@@ -630,6 +678,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -694,8 +743,8 @@ class ApplicationsController extends Controller
}
#[OA\Post(
summary: 'Create (Docker Image)',
description: 'Create new application based on a prebuilt docker image',
summary: 'Create (Docker Image without git)',
description: 'Create new application based on a prebuilt docker image (without git).',
path: '/applications/dockerimage',
operationId: 'create-dockerimage-application',
security: [
@@ -762,6 +811,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -825,11 +875,15 @@ class ApplicationsController extends Controller
return $this->create_application($request, 'dockerimage');
}
/**
* @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)',
description: 'Create new application based on a docker-compose file.',
summary: 'Create (Docker Compose) (Deprecated)',
description: 'Create new application based on a docker-compose file (without git).',
path: '/applications/dockercompose',
operationId: 'create-dockercompose-application',
deprecated: true,
security: [
['bearerAuth' => []],
],
@@ -856,6 +910,7 @@ class ApplicationsController extends Controller
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -932,7 +987,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', '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'];
$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'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -977,6 +1032,7 @@ class ApplicationsController extends Controller
$isStatic = $request->is_static;
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -988,7 +1044,7 @@ class ApplicationsController extends Controller
], 422);
}
$customNginxConfiguration = base64_decode($customNginxConfiguration);
if (mb_detect_encoding($customNginxConfiguration, 'ASCII', true) === false) {
if (mb_detect_encoding($customNginxConfiguration, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1028,8 +1084,10 @@ class ApplicationsController extends Controller
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string',
'docker_compose_raw' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1037,13 +1095,26 @@ class ApplicationsController extends Controller
$request->offsetSet('ports_exposes', '80');
}
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
// For dockercompose applications, domains (fqdn) field should not be used
// Only docker_compose_domains should be used to set domains for individual services
if ($request->build_pack === 'dockercompose' && $request->has('domains')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'domains' => 'The domains field cannot be used for dockercompose applications. Use docker_compose_domains instead to set domains for individual services.',
],
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
}
@@ -1059,11 +1130,42 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) {
$dockerComposeDomains->each(function ($domain, $key) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
$domainErrors = [];
foreach ($dockerComposeDomains as $index => $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 (! empty($domainErrors)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_domains' => $domainErrors,
],
], 422);
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
@@ -1093,6 +1195,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1138,17 +1244,32 @@ class ApplicationsController extends Controller
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_raw' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
// For dockercompose applications, domains (fqdn) field should not be used
// Only docker_compose_domains should be used to set domains for individual services
if ($request->build_pack === 'dockercompose' && $request->has('domains')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'domains' => 'The domains field cannot be used for dockercompose applications. Use docker_compose_domains instead to set domains for individual services.',
],
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
@@ -1197,43 +1318,43 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
if (! $request->has('docker_compose_raw')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
$domainErrors = [];
foreach ($dockerComposeDomains as $index => $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 (! empty($domainErrors)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The base64 encoded docker_compose_raw is required.',
'docker_compose_domains' => $domainErrors,
],
], 422);
}
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$yaml = Yaml::parse($dockerComposeRaw);
$services = data_get($yaml, 'services');
$dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) {
$dockerComposeDomains->each(function ($domain, $key) use ($services, $dockerComposeDomainsJson) {
$name = data_get($domain, 'name');
if (data_get($services, $name)) {
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
}
});
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
@@ -1259,6 +1380,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1299,11 +1424,17 @@ class ApplicationsController extends Controller
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_raw' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
return response()->json([
@@ -1311,6 +1442,16 @@ class ApplicationsController extends Controller
'errors' => $validator->errors(),
], 422);
}
// For dockercompose applications, domains (fqdn) field should not be used
// Only docker_compose_domains should be used to set domains for individual services
if ($request->build_pack === 'dockercompose' && $request->has('domains')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'domains' => 'The domains field cannot be used for dockercompose applications. Use docker_compose_domains instead to set domains for individual services.',
],
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
}
@@ -1334,44 +1475,43 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
if (! $request->has('docker_compose_raw')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
$domainErrors = [];
foreach ($dockerComposeDomains as $index => $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 (! empty($domainErrors)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The base64 encoded docker_compose_raw is required.',
'docker_compose_domains' => $domainErrors,
],
], 422);
}
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
$yaml = Yaml::parse($dockerComposeRaw);
$services = data_get($yaml, 'services');
$dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) {
$dockerComposeDomains->each(function ($domain, $key) use ($services, $dockerComposeDomainsJson) {
$name = data_get($domain, 'name');
if (data_get($services, $name)) {
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
}
});
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
});
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
@@ -1393,6 +1533,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1453,7 +1597,7 @@ class ApplicationsController extends Controller
], 422);
}
$dockerFile = base64_decode($request->dockerfile);
if (mb_detect_encoding($dockerFile, 'ASCII', true) === false) {
if (mb_detect_encoding($dockerFile, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1492,6 +1636,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1590,6 +1738,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1617,7 +1769,7 @@ class ApplicationsController extends Controller
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockercompose') {
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -1661,7 +1813,7 @@ class ApplicationsController extends Controller
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1681,6 +1833,9 @@ class ApplicationsController extends Controller
$service->server_id = $server->id;
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
if (isset($isContainerLabelEscapeEnabled)) {
$service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
}
$service->save();
$service->parse(isNew: true);
@@ -1718,7 +1873,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1786,7 +1940,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -1888,7 +2041,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)),
@@ -1975,7 +2127,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2043,14 +2194,24 @@ class ApplicationsController extends Controller
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'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.'],
'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'],
'docker_compose_domains' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a dockercompose application.',
'items' => new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
],
),
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -2136,7 +2297,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', '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_raw', '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'];
$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'];
$validationRules = [
'name' => 'string|max:255',
@@ -2144,8 +2305,10 @@ class ApplicationsController extends Controller
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_raw' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
@@ -2154,7 +2317,10 @@ class ApplicationsController extends Controller
'http_basic_auth_password' => 'string',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
// Validate ports_exposes
if ($request->has('ports_exposes')) {
@@ -2180,7 +2346,7 @@ class ApplicationsController extends Controller
], 422);
}
$customNginxConfiguration = base64_decode($request->custom_nginx_configuration);
if (mb_detect_encoding($customNginxConfiguration, 'ASCII', true) === false) {
if (mb_detect_encoding($customNginxConfiguration, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -2230,6 +2396,17 @@ class ApplicationsController extends Controller
$application->save();
}
// For dockercompose applications, domains (fqdn) field should not be used
// Only docker_compose_domains should be used to set domains for individual services
if ($application->build_pack === 'dockercompose' && $request->has('domains')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'domains' => 'The domains field cannot be used for dockercompose applications. Use docker_compose_domains instead to set domains for individual services.',
],
], 422);
}
$domains = $request->domains;
$requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) {
@@ -2273,50 +2450,64 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
if (! $request->has('docker_compose_raw')) {
if (empty($application->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The base64 encoded docker_compose_raw is required.',
'docker_compose_domains' => 'Cannot set docker_compose_domains without docker_compose_raw. Reload the compose file from the git repository first.',
],
], 422);
}
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
$yaml = Yaml::parse($dockerComposeRaw);
$services = data_get($yaml, 'services');
$dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) {
$dockerComposeDomains->each(function ($domain, $key) use ($services, $dockerComposeDomainsJson) {
$name = data_get($domain, 'name');
if (data_get($services, $name)) {
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
}
});
$domainErrors = [];
foreach ($dockerComposeDomains as $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 (! empty($domainErrors)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_domains' => $domainErrors,
],
], 422);
}
$yaml = Yaml::parse($application->docker_compose_raw);
$services = data_get($yaml, 'services', []);
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) {
$name = data_get($domain, 'name');
if ($name && is_array($services) && isset($services[$name])) {
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
}
});
$request->offsetUnset('docker_compose_domains');
}
$instantDeploy = $request->instant_deploy;
$isStatic = $request->is_static;
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
@@ -2333,6 +2524,11 @@ class ApplicationsController extends Controller
$application->settings->save();
}
if ($request->has('is_container_label_escape_enabled')) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
removeUnnecessaryFieldsFromRequest($request);
$data = $request->all();
@@ -2386,7 +2582,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2472,7 +2667,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2662,7 +2856,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2703,10 +2896,8 @@ class ApplicationsController 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')
)
),
]
@@ -2872,7 +3063,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -3038,7 +3228,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -3048,7 +3237,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -3131,7 +3319,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -3247,7 +3434,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -3324,7 +3510,6 @@ class ApplicationsController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -3430,7 +3615,7 @@ class ApplicationsController extends Controller
], 422);
}
$customLabels = base64_decode($request->custom_labels);
if (mb_detect_encoding($customLabels, 'ASCII', true) === false) {
if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -411,7 +411,6 @@ class CloudProviderTokensController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -117,7 +117,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -182,7 +181,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -245,7 +243,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -392,7 +389,7 @@ class DatabasesController extends Controller
], 422);
}
$postgresConf = base64_decode($request->postgres_conf);
if (mb_detect_encoding($postgresConf, 'ASCII', true) === false) {
if (mb_detect_encoding($postgresConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -432,7 +429,7 @@ class DatabasesController extends Controller
], 422);
}
$redisConf = base64_decode($request->redis_conf);
if (mb_detect_encoding($redisConf, 'ASCII', true) === false) {
if (mb_detect_encoding($redisConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -459,7 +456,7 @@ class DatabasesController extends Controller
], 422);
}
$keydbConf = base64_decode($request->keydb_conf);
if (mb_detect_encoding($keydbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($keydbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -489,7 +486,7 @@ class DatabasesController extends Controller
], 422);
}
$mariadbConf = base64_decode($request->mariadb_conf);
if (mb_detect_encoding($mariadbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mariadbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -518,7 +515,7 @@ class DatabasesController extends Controller
], 422);
}
$mongoConf = base64_decode($request->mongo_conf);
if (mb_detect_encoding($mongoConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mongoConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -549,7 +546,7 @@ class DatabasesController extends Controller
], 422);
}
$mysqlConf = base64_decode($request->mysql_conf);
if (mb_detect_encoding($mysqlConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mysqlConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -614,7 +611,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -832,7 +828,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -842,7 +837,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1707,7 +1701,7 @@ class DatabasesController extends Controller
], 422);
}
$postgresConf = base64_decode($request->postgres_conf);
if (mb_detect_encoding($postgresConf, 'ASCII', true) === false) {
if (mb_detect_encoding($postgresConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1762,7 +1756,7 @@ class DatabasesController extends Controller
], 422);
}
$mariadbConf = base64_decode($request->mariadb_conf);
if (mb_detect_encoding($mariadbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mariadbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1821,7 +1815,7 @@ class DatabasesController extends Controller
], 422);
}
$mysqlConf = base64_decode($request->mysql_conf);
if (mb_detect_encoding($mysqlConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mysqlConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1877,7 +1871,7 @@ class DatabasesController extends Controller
], 422);
}
$redisConf = base64_decode($request->redis_conf);
if (mb_detect_encoding($redisConf, 'ASCII', true) === false) {
if (mb_detect_encoding($redisConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1963,7 +1957,7 @@ class DatabasesController extends Controller
], 422);
}
$keydbConf = base64_decode($request->keydb_conf);
if (mb_detect_encoding($keydbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($keydbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -2057,7 +2051,7 @@ class DatabasesController extends Controller
], 422);
}
$mongoConf = base64_decode($request->mongo_conf);
if (mb_detect_encoding($mongoConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mongoConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -2104,7 +2098,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)),
@@ -2193,7 +2186,7 @@ class DatabasesController extends Controller
in: 'path',
required: true,
description: 'UUID of the backup configuration to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'delete_s3',
@@ -2310,14 +2303,14 @@ class DatabasesController extends Controller
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'execution_uuid',
in: 'path',
required: true,
description: 'UUID of the backup execution to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'delete_s3',
@@ -2430,7 +2423,7 @@ class DatabasesController extends Controller
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
],
responses: [
@@ -2527,7 +2520,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2608,7 +2600,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2689,7 +2680,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -538,7 +538,6 @@ class DeployController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -285,7 +285,6 @@ class ProjectController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -406,7 +405,6 @@ class ProjectController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -699,7 +699,6 @@ class ServersController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
+19 -109
View File
@@ -105,98 +105,7 @@ class ServicesController extends Controller
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
properties: [
'type' => [
'description' => 'The one-click service type',
'type' => 'string',
'enum' => [
'activepieces',
'appsmith',
'appwrite',
'authentik',
'babybuddy',
'budge',
'changedetection',
'chatwoot',
'classicpress-with-mariadb',
'classicpress-with-mysql',
'classicpress-without-database',
'cloudflared',
'code-server',
'dashboard',
'directus',
'directus-with-postgresql',
'docker-registry',
'docuseal',
'docuseal-with-postgres',
'dokuwiki',
'duplicati',
'emby',
'embystat',
'fider',
'filebrowser',
'firefly',
'formbricks',
'ghost',
'gitea',
'gitea-with-mariadb',
'gitea-with-mysql',
'gitea-with-postgresql',
'glance',
'glances',
'glitchtip',
'grafana',
'grafana-with-postgresql',
'grocy',
'heimdall',
'homepage',
'jellyfin',
'kuzzle',
'listmonk',
'logto',
'mediawiki',
'meilisearch',
'metabase',
'metube',
'minio',
'moodle',
'n8n',
'n8n-with-postgresql',
'next-image-transformation',
'nextcloud',
'nocodb',
'odoo',
'openblocks',
'pairdrop',
'penpot',
'phpmyadmin',
'pocketbase',
'posthog',
'reactive-resume',
'rocketchat',
'shlink',
'slash',
'snapdrop',
'statusnook',
'stirling-pdf',
'supabase',
'syncthing',
'tolgee',
'trigger',
'trigger-with-external-database',
'twenty',
'umami',
'unleash-with-postgresql',
'unleash-without-database',
'uptime-kuma',
'vaultwarden',
'vikunja',
'weblate',
'whoogle',
'wordpress-with-mariadb',
'wordpress-with-mysql',
'wordpress-without-database',
],
],
'type' => ['description' => 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)', 'type' => 'string'],
'name' => ['type' => 'string', 'maxLength' => 255, 'description' => 'Name of the service.'],
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Description of the service.'],
'project_uuid' => ['type' => 'string', 'description' => 'Project UUID.'],
@@ -205,7 +114,7 @@ class ServicesController extends Controller
'server_uuid' => ['type' => 'string', 'description' => 'Server UUID.'],
'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'],
'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
],
),
),
@@ -283,6 +192,13 @@ class ServicesController extends Controller
'errors' => $errors,
], 422);
}
if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
], 422);
}
$environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
@@ -355,7 +271,8 @@ class ServicesController extends Controller
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
$service->name = "$oneClickServiceName-".$service->uuid;
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -469,7 +386,7 @@ class ServicesController extends Controller
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -524,8 +441,11 @@ class ServicesController extends Controller
'uuid' => $service->uuid,
'domains' => $domains,
])->setStatusCode(201);
} else {
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
} elseif (filled($request->type)) {
return response()->json([
'message' => 'Invalid service type.',
'valid_service_types' => $serviceKeys,
], 404);
}
}
@@ -681,7 +601,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -703,7 +622,7 @@ class ServicesController extends Controller
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
],
)
),
@@ -797,7 +716,7 @@ class ServicesController extends Controller
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -871,7 +790,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -952,7 +870,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1069,7 +986,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1200,7 +1116,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1315,7 +1230,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -1325,7 +1239,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1404,7 +1317,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1485,7 +1397,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1566,7 +1477,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
+8 -2
View File
@@ -218,7 +218,10 @@ class TeamController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$team = auth()->user()->currentTeam();
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
return response()->json(
$this->removeSensitiveData($team),
@@ -263,7 +266,10 @@ class TeamController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$team = auth()->user()->currentTeam();
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$team->members->makeHidden([
'pivot',
'email_change_code',
+8 -4
View File
@@ -13,7 +13,8 @@ class UploadController extends BaseController
{
public function upload(Request $request)
{
$resource = getResourceByUuid(request()->route('databaseUuid'), data_get(auth()->user()->currentTeam(), 'id'));
$databaseIdentifier = request()->route('databaseUuid');
$resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
@@ -26,7 +27,10 @@ class UploadController extends BaseController
$save = $receiver->receive();
if ($save->isFinished()) {
return $this->saveFile($save->getFile(), $resource);
// Use the original identifier from the route to maintain path consistency
// For ServiceDatabase: {name}-{service_uuid}
// For standalone databases: {uuid}
return $this->saveFile($save->getFile(), $databaseIdentifier);
}
$handler = $save->handler();
@@ -57,10 +61,10 @@ class UploadController extends BaseController
// 'mime_type' => $mime
// ]);
// }
protected function saveFile(UploadedFile $file, $resource)
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resource->uuid}";
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
$file->move($finalPath, 'restore');
+1 -1
View File
@@ -90,7 +90,7 @@ class Gitea extends Controller
if ($x_gitea_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
+61 -170
View File
@@ -2,13 +2,10 @@
namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
use App\Http\Controllers\Controller;
use App\Jobs\ApplicationPullRequestUpdateJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
@@ -54,6 +51,8 @@ class Github extends Controller
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! $branch) {
@@ -69,7 +68,7 @@ class Github extends Controller
if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
}
$applicationsByServer = $applications->groupBy(function ($app) {
@@ -102,7 +101,7 @@ class Github extends Controller
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -152,96 +151,35 @@ class Github extends Controller
}
}
if ($x_github_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
if ($application->isPRDeployable()) {
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! in_array($author_association, $trustedAssociations)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
]);
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
}
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$pr_app->generate_preview_fqdn_compose();
} else {
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment(
application: $application,
pull_request_id: $pull_request_id,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'head.sha', 'HEAD'),
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment queued.',
]);
}
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
}
continue;
}
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Use comprehensive cleanup that cancels active deployments,
// kills helper containers, and removes all PR containers
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
}
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: null,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
@@ -304,6 +242,8 @@ class Github extends Controller
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! $id || ! $branch) {
@@ -344,7 +284,7 @@ class Github extends Controller
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -386,86 +326,37 @@ class Github extends Controller
}
}
if ($x_github_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
if ($application->isPRDeployable()) {
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! in_array($author_association, $trustedAssociations)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
]);
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
}
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
}
$result = queue_application_deployment(
application: $application,
pull_request_id: $pull_request_id,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'head.sha', 'HEAD'),
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment queued.',
]);
}
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
}
continue;
}
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Delete the PR comment on GitHub (GitHub-specific feature)
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
// Use comprehensive cleanup that cancels active deployments,
// kills helper containers, and removes all PR containers
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
$full_name = data_get($payload, 'repository.full_name');
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
}
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: $github_app->id,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
+2 -2
View File
@@ -100,7 +100,7 @@ class Gitlab extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if ($webhook_secret !== $x_gitlab_token) {
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -122,7 +122,7 @@ class Gitlab extends Controller
if ($x_gitlab_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -18,14 +18,21 @@ class DecideWhatToDoWithUser
}
if (auth()?->user()?->currentTeam()) {
refreshSession(auth()->user()->currentTeam());
} elseif (auth()?->user()?->teams?->count() > 0) {
// User's session team is invalid (e.g., removed from team), switch to first available team
refreshSession(auth()->user()->teams->first());
}
if (! auth()->user() || ! isCloud() || isInstanceAdmin()) {
if (! auth()->user() || ! isCloud()) {
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {
return redirect()->route('onboarding');
}
return $next($request);
}
// Instance admins can access settings and admin routes regardless of subscription
if (isInstanceAdmin() && ($request->routeIs('settings.*') || $request->path() === 'admin')) {
return $next($request);
}
if (! auth()->user()->hasVerifiedEmail()) {
if ($request->path() === 'verify' || in_array($request->path(), allowedPathsForInvalidAccounts()) || $request->routeIs('verify.verify')) {
return $next($request);
+36 -27
View File
@@ -87,9 +87,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $use_build_server = false;
// Save original server between phases
private Server $original_server;
private Server $mainServer;
private bool $is_this_additional_server = false;
@@ -325,18 +322,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($buildServers->count() === 0) {
$this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.');
$this->build_server = $this->server;
$this->original_server = $this->server;
} else {
$this->build_server = $buildServers->random();
$this->application_deployment_queue->build_server_id = $this->build_server->id;
$this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name}).");
$this->original_server = $this->server;
$this->use_build_server = true;
}
} else {
// Set build server & original_server to the same as deployment server
$this->build_server = $this->server;
$this->original_server = $this->server;
}
$this->detectBuildKitCapabilities();
$this->decide_what_to_do();
@@ -937,7 +930,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
if ($this->preserveRepository) {
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->server = $this->mainServer;
}
if (str($this->configuration_dir)->isNotEmpty()) {
$this->execute_remote_command(
@@ -960,7 +953,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
if (isset($this->docker_compose_base64)) {
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->server = $this->mainServer;
}
$readme = generate_readme_file($this->application->name, $this->application_deployment_queue->updated_at);
@@ -1342,7 +1335,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Also create in configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->server = $this->mainServer;
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
@@ -1359,7 +1352,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
// For non-Docker Compose deployments, clean up any existing .env files
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->server = $this->mainServer;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
@@ -1397,17 +1390,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
'hidden' => true,
]
);
if (isDev()) {
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
'hidden' => true,
]
);
}
// Write .env file to configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->server = $this->mainServer;
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
@@ -1656,12 +1653,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
],
[
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
'hidden' => true,
],
]
);
if (isDev()) {
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
'hidden' => true,
]
);
}
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
@@ -1744,7 +1746,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->original_server;
$this->server = $this->mainServer;
}
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
@@ -1913,7 +1915,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function create_workdir()
{
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->server = $this->mainServer;
$this->execute_remote_command(
[
'command' => "mkdir -p {$this->configuration_dir}",
@@ -2271,7 +2273,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
$this->nixpacks_plan_json = collect($parsed);
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
} else {
$parsedForLog = $parsed;
unset($parsedForLog['variables']); // remove variables section to avoid exposing ENVs in production logs
$this->application_deployment_queue->addLogEntry('Final Nixpacks plan: '.json_encode($parsedForLog, JSON_PRETTY_PRINT), hidden: true);
}
if ($this->nixpacks_type === 'rust') {
// temporary: disable healthcheck for rust because the start phase does not have curl/wget
$this->application->health_check_enabled = false;
@@ -2567,7 +2576,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (! is_null($this->application->limits_cpuset)) {
data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
}
if ($this->server->isSwarm()) {
if ($this->mainServer->isSwarm()) {
data_forget($docker_compose, 'services.'.$this->container_name.'.container_name');
data_forget($docker_compose, 'services.'.$this->container_name.'.expose');
data_forget($docker_compose, 'services.'.$this->container_name.'.restart');
@@ -2617,7 +2626,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
$docker_compose['services'][$this->container_name]['labels'] = $labels;
}
if ($this->server->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) {
if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) {
$docker_compose['services'][$this->container_name]['logging'] = generate_fluentd_configuration();
}
if ($this->application->settings->is_gpu_enabled) {
+2 -1
View File
@@ -54,8 +54,9 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
};
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$application_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/logs";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.') | [Open Application Logs]('.$application_logs_url.")\n\n\n";
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';
if ($this->preview->pull_request_issue_comment_id) {
$this->update_comment();
@@ -0,0 +1,152 @@
<?php
namespace App\Jobs;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Visus\Cuid2\Cuid2;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public array $backoff = [30, 60, 120];
public function __construct(
public int $applicationId,
public ?int $githubAppId,
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
public ?string $authorAssociation,
public string $fullName,
) {
$this->onQueue('high');
}
public function handle(): void
{
$application = Application::find($this->applicationId);
if (! $application) {
return;
}
$githubApp = $this->githubAppId ? GithubApp::find($this->githubAppId) : null;
if ($this->action === 'closed' || $this->action === 'close') {
$this->handleClosedAction($application);
return;
}
if ($this->action === 'opened' || $this->action === 'synchronize' || $this->action === 'reopened') {
$this->handleOpenAction($application, $githubApp);
}
}
private function handleClosedAction(Application $application): void
{
$found = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', $this->pullRequestId)
->first();
if ($found) {
ApplicationPullRequestUpdateJob::dispatchSync(
application: $application,
preview: $found,
status: ProcessStatus::CLOSED
);
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found);
}
}
private function handleOpenAction(Application $application, ?GithubApp $githubApp): void
{
if (! $application->isPRDeployable()) {
return;
}
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! in_array($this->authorAssociation, $trustedAssociations)) {
return;
}
}
// Get changed files for watch path filtering
$changed_files = collect();
$repository_parts = explode('/', $this->fullName);
$owner = $repository_parts[0] ?? '';
$repo = $repository_parts[1] ?? '';
if ($this->action === 'synchronize' && $this->beforeSha && $this->afterSha) {
// For synchronize events, get files changed between before and after commits
$changed_files = collect(getGithubCommitRangeFiles($githubApp, $owner, $repo, $this->beforeSha, $this->afterSha));
} elseif ($this->action === 'opened' || $this->action === 'reopened') {
// For opened/reopened events, get all files in the PR
$changed_files = collect(getGithubPullRequestFiles($githubApp, $owner, $repo, $this->pullRequestId));
}
// Apply watch path filtering
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if (! $is_watch_path_triggered && ! blank($application->watch_paths)) {
return;
}
// Create ApplicationPreview if not exists
$found = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', $this->pullRequestId)
->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
$preview = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $this->pullRequestId,
'pull_request_html_url' => $this->pullRequestHtmlUrl,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$preview->generate_preview_fqdn_compose();
} else {
$preview = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $this->pullRequestId,
'pull_request_html_url' => $this->pullRequestHtmlUrl,
]);
$preview->generate_preview_fqdn();
}
}
// Queue the deployment
$deployment_uuid = new Cuid2;
queue_application_deployment(
application: $application,
pull_request_id: $this->pullRequestId,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: $this->commitSha,
is_webhook: true,
git_type: 'github'
);
}
}
@@ -29,7 +29,8 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
$server->proxy->set('status', $status);
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
$versionCheckDispatched = false;
if ($status === 'running') {
$server->setupDefaultRedirect();
$server->setupDynamicProxyConfiguration();
@@ -40,7 +41,9 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$traefikVersions = get_traefik_versions();
if ($traefikVersions !== null) {
// Version check job will dispatch ProxyStatusChangedUI when complete
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
$versionCheckDispatched = true;
} else {
Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
'server_id' => $server->id,
@@ -49,6 +52,13 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
}
}
}
// Only dispatch UI refresh if version check wasn't dispatched
// (version check job handles its own UI refresh with updated version data)
if (! $versionCheckDispatched) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
if ($status === 'created') {
instant_remote_process([
'docker rm -f coolify-proxy',
+4 -2
View File
@@ -79,8 +79,10 @@ class ActivityMonitor extends Component
$causer_id = data_get($this->activity, 'causer_id');
$user = User::find($causer_id);
if ($user) {
$teamId = $user->currentTeam()->id;
if (! self::$eventDispatched) {
$teamId = data_get($this->activity, 'properties.team_id')
?? $user->currentTeam()?->id
?? $user->teams->first()?->id;
if ($teamId && ! self::$eventDispatched) {
if (filled($this->eventData)) {
$this->eventToDispatch::dispatch($teamId, $this->eventData);
} else {
+1 -1
View File
@@ -95,7 +95,7 @@ class Docker extends Component
]);
}
}
$this->redirect(route('destination.show', $docker->uuid));
redirectRoute($this, 'destination.show', [$docker->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+38 -2
View File
@@ -1314,10 +1314,10 @@ class GlobalSearch extends Component
'server_id' => $this->selectedServerId,
];
$this->redirect(route('project.resource.create', [
redirectRoute($this, 'project.resource.create', [
'project_uuid' => $this->selectedProjectUuid,
'environment_uuid' => $this->selectedEnvironmentUuid,
] + $queryParams));
] + $queryParams);
}
}
@@ -1336,6 +1336,42 @@ class GlobalSearch extends Component
$this->autoOpenResource = null;
}
public function goBack()
{
// From Environment Selection → go back to Project (if multiple) or further
if ($this->selectedProjectUuid !== null) {
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableProjects) > 1) {
return; // Stop here - user can choose a project
}
}
// From Project Selection → go back to Destination (if multiple) or further
if ($this->selectedDestinationUuid !== null) {
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableDestinations) > 1) {
return; // Stop here - user can choose a destination
}
}
// From Destination Selection → go back to Server (if multiple) or cancel
if ($this->selectedServerId !== null) {
$this->selectedServerId = null;
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableServers) > 1) {
return; // Stop here - user can choose a server
}
}
// All previous steps were auto-selected, cancel entirely
$this->cancelResourceSelection();
}
public function getFilteredCreatableItemsProperty()
{
$query = strtolower(trim($this->searchQuery));
+1 -1
View File
@@ -37,7 +37,7 @@ class NavbarDeleteTeam extends Component
refreshSession();
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
}
public function render()
@@ -121,6 +121,25 @@ class Show extends Component
return sanitizeLogsForExport($logs);
}
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) {
$prefix = '';
if ($line['hidden']) {
$prefix = '[DEBUG] ';
}
if (isset($line['command']) && $line['command']) {
$prefix .= '[CMD]: ';
}
return $line['timestamp'].' '.$prefix.trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function render()
{
return view('livewire.project.application.deployment.show');
+5 -71
View File
@@ -232,8 +232,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',
'buildPack.required' => 'The Build Pack field is required.',
@@ -558,8 +556,11 @@ class General extends Component
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
} catch (\Throwable $e) {
$this->application->docker_compose_location = $this->initialDockerComposeLocation;
$this->application->save();
// Refresh model to get restored values from Application::loadComposeFile
$this->application->refresh();
// Sync restored values back to component properties for UI update
$this->syncData();
return handleError($e, $this);
} finally {
@@ -936,73 +937,6 @@ class General extends Component
]);
}
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
$fqdnValue = str($domain)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Create/update SERVICE_FQDN_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
// Create/update SERVICE_URL_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if (filled($port)) {
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
}
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();
@@ -66,7 +66,7 @@ class Rollback extends Component
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $deployment_uuid,
+7 -7
View File
@@ -8,7 +8,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class BackupEdit extends Component
{
@@ -184,13 +183,14 @@ class BackupEdit extends Component
$this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$previousUrl = url()->previous();
$url = Url::fromString($previousUrl);
$url = $url->withoutQueryParameter('selectedBackupId');
$url = $url->withFragment('backups');
$url = $url->getPath()."#{$url->getFragment()}";
$serviceDatabase = $this->backup->database;
return redirect($url);
return redirect()->route('project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $serviceDatabase->service->uuid,
'stack_service_uuid' => $serviceDatabase->uuid,
]);
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
}
@@ -168,21 +168,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -178,21 +178,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+212 -49
View File
@@ -4,9 +4,11 @@ namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Import extends Component
@@ -101,11 +103,23 @@ class Import extends Component
public bool $unsupported = false;
public $resource;
// Store IDs instead of models for proper Livewire serialization
public ?int $resourceId = null;
public $parameters;
public ?string $resourceType = null;
public $containers;
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
public string $resourceUuid = '';
public string $resourceStatus = '';
public string $resourceDbType = '';
public array $parameters = [];
public array $containers = [];
public bool $scpInProgress = false;
@@ -121,8 +135,6 @@ class Import extends Component
public bool $error = false;
public Server $server;
public string $container;
public array $importCommands = [];
@@ -135,7 +147,7 @@ class Import extends Component
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
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';
@@ -144,7 +156,7 @@ class Import extends Component
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public $availableS3Storages = [];
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
@@ -152,6 +164,26 @@ class Import extends Component
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
}
return $this->resourceType::find($this->resourceId);
}
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
}
return Server::find($this->serverId);
}
public function getListeners()
{
$userId = Auth::id();
@@ -176,47 +208,64 @@ class Import extends Component
public function updatedDumpAll($value)
{
switch ($this->resource->getMorphClass()) {
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`default\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD default
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD default';
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`default\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD default
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD default';
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$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
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}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U $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';
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
}
break;
}
@@ -225,30 +274,99 @@ EOD;
public function getContainers()
{
$this->containers = collect();
if (! data_get($this->parameters, 'database_uuid')) {
abort(404);
}
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource);
$this->resource = $resource;
$this->server = $this->resource->destination->server;
$this->container = $this->resource->uuid;
if (str(data_get($this, 'resource.status'))->startsWith('running')) {
$this->containers->push($this->container);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
// Store view-friendly properties
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase
$dbType = $resource->databaseType();
if (str_contains($dbType, 'postgres')) {
$this->resourceDbType = 'standalone-postgresql';
} elseif (str_contains($dbType, 'mysql')) {
$this->resourceDbType = 'standalone-mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb';
} elseif (str_contains($dbType, 'mongo')) {
$this->resourceDbType = 'standalone-mongodb';
} else {
$this->resourceDbType = $dbType;
}
} else {
$server = $resource->destination?->server;
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
}
if (
$this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile()
@@ -261,6 +379,12 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
@@ -286,15 +410,22 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resource->uuid}/restore";
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
@@ -305,7 +436,7 @@ EOD;
return;
}
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
@@ -315,7 +446,7 @@ EOD;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
@@ -355,9 +486,11 @@ EOD;
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get();
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = collect();
$this->availableS3Storages = [];
}
}
@@ -460,6 +593,12 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$this->importRunning = true;
@@ -493,14 +632,18 @@ EOD;
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}";
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence
$commands = [];
@@ -575,32 +718,52 @@ EOD;
public function buildRestoreCommand(string $tmpPath): string
{
switch ($this->resource->getMorphClass()) {
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$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}";
}
break;
case \App\Models\StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
@@ -185,21 +185,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -91,8 +91,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mariadbRootPassword.required' => 'The Root Password field is required.',
'mariadbUser.required' => 'The MariaDB User field is required.',
'mariadbPassword.required' => 'The MariaDB Password field is required.',
@@ -234,22 +232,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -91,8 +91,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
@@ -237,22 +235,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -94,8 +94,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mysqlRootPassword.required' => 'The Root Password field is required.',
'mysqlUser.required' => 'The MySQL User field is required.',
'mysqlPassword.required' => 'The MySQL Password field is required.',
@@ -241,22 +239,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -106,8 +106,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'postgresUser.required' => 'The Postgres User field is required.',
'postgresPassword.required' => 'The Postgres Password field is required.',
'postgresDb.required' => 'The Postgres Database field is required.',
@@ -288,22 +286,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -88,8 +88,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'redisUsername.required' => 'The Redis Username field is required.',
@@ -227,21 +225,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+1 -1
View File
@@ -39,7 +39,7 @@ class DeleteEnvironment extends Component
if ($environment->isEmpty()) {
$environment->delete();
return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
+1 -1
View File
@@ -35,7 +35,7 @@ class DeleteProject extends Component
if ($project->isEmpty()) {
$project->delete();
return redirect()->route('project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
+1 -1
View File
@@ -63,7 +63,7 @@ class EnvironmentEdit extends Component
{
try {
$this->syncData(true);
$this->redirectRoute('project.environment.edit', [
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
'project_uuid' => $this->project->uuid,
]);
+1 -1
View File
@@ -154,7 +154,7 @@ class DockerImage extends Component
'fqdn' => $fqdn,
]);
return redirect()->route('project.application.configuration', [
return redirectRoute($this, 'project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,
+1 -1
View File
@@ -16,6 +16,6 @@ class EmptyProject extends Component
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
}
}
-228
View File
@@ -1,228 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Database extends Component
{
use AuthorizesRequests;
public ServiceDatabase $database;
public ?string $db_url_public = null;
public $fileStorages;
public $parameters;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
protected $listeners = ['refreshFileStorages'];
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
];
public function render()
{
return view('livewire.project.service.database');
}
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->database);
if ($this->database->is_public) {
$this->db_url_public = $this->database->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
$this->syncData(false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->human_name = $this->humanName;
$this->database->description = $this->description;
$this->database->image = $this->image;
$this->database->exclude_from_status = $this->excludeFromStatus;
$this->database->public_port = $this->publicPort;
$this->database->is_public = $this->isPublic;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->database->human_name;
$this->description = $this->database->description;
$this->image = $this->database->image;
$this->excludeFromStatus = $this->database->exclude_from_status ?? false;
$this->publicPort = $this->database->public_port;
$this->isPublic = $this->database->is_public ?? false;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false;
}
}
public function delete($password)
{
try {
$this->authorize('delete', $this->database);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();
$this->dispatch('success', 'Database deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveExclude()
{
try {
$this->authorize('update', $this->database);
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveLogDrain()
{
try {
$this->authorize('update', $this->database);
if (! $this->database->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->submit();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToApplication()
{
try {
$this->authorize('update', $this->database);
$service = $this->database->service;
$serviceDatabase = $this->database;
// Check if application with same name already exists
if ($service->applications()->where('name', $serviceDatabase->name)->exists()) {
throw new \Exception('An application with this name already exists.');
}
// Create new parameters removing database_uuid
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceDatabase) {
$service->applications()->create([
'name' => $serviceDatabase->name,
'human_name' => $serviceDatabase->human_name,
'description' => $serviceDatabase->description,
'exclude_from_status' => $serviceDatabase->exclude_from_status,
'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled,
'image' => $serviceDatabase->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceDatabase->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->database);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncData(true);
if ($this->database->is_public) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->database->is_public = false;
return;
}
StartDatabaseProxy::run($this->database);
$this->db_url_public = $this->database->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshFileStorages()
{
$this->fileStorages = $this->database->fileStorages()->get();
}
public function submit()
{
try {
$this->authorize('update', $this->database);
$this->validate();
$this->syncData(true);
$this->database->save();
$this->database->refresh();
$this->syncData(false);
updateCompose($this->database);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('generateDockerCompose');
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class DatabaseBackups extends Component
{
use AuthorizesRequests;
public ?Service $service = null;
public ?ServiceDatabase $serviceDatabase = null;
public array $parameters;
public array $query;
public bool $isImportSupported = false;
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
// Check if backups are supported for this database
if (! $this->serviceDatabase->isBackupSolutionAvailable() && ! $this->serviceDatabase->is_migrated) {
return redirect()->route('project.service.index', $this->parameters);
}
// Check if import is supported for this database type
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.database-backups');
}
}
+487 -1
View File
@@ -2,12 +2,17 @@
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component
{
@@ -19,6 +24,10 @@ class Index extends Component
public ?ServiceDatabase $serviceDatabase = null;
public ?string $resourceType = null;
public ?string $currentRoute = null;
public array $parameters;
public array $query;
@@ -27,7 +36,67 @@ class Index extends Component
public $s3s;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh'];
public ?Server $server = null;
// Database-specific properties
public ?string $db_url_public = null;
public $fileStorages;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
public bool $isImportSupported = false;
// Application-specific properties
public $docker_cleanup = true;
public $delete_volumes = true;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
public ?string $fqdn = null;
public bool $isGzipEnabled = false;
public bool $isStripprefixEnabled = false;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages'];
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
// Application-specific rules
'fqdn' => 'nullable',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function mount()
{
@@ -35,6 +104,7 @@ class Index extends Component
$this->services = collect([]);
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
@@ -43,10 +113,21 @@ class Index extends Component
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
$this->serviceApplication = $service;
$this->resourceType = 'application';
$this->serviceApplication->getFilesFromServer();
$this->initializeApplicationProperties();
} else {
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
$this->resourceType = 'database';
$this->serviceDatabase->getFilesFromServer();
$this->initializeDatabaseProperties();
}
$this->s3s = currentTeam()->s3s;
} catch (\Throwable $e) {
@@ -54,6 +135,42 @@ class Index extends Component
}
}
private function initializeDatabaseProperties(): void
{
$this->server = $this->serviceDatabase->service->destination->server;
if ($this->serviceDatabase->is_public) {
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
$this->syncDatabaseData(false);
// Check if import is supported for this database type
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
}
private function syncDatabaseData(bool $toModel = false): void
{
if ($toModel) {
$this->serviceDatabase->human_name = $this->humanName;
$this->serviceDatabase->description = $this->description;
$this->serviceDatabase->image = $this->image;
$this->serviceDatabase->exclude_from_status = $this->excludeFromStatus;
$this->serviceDatabase->public_port = $this->publicPort;
$this->serviceDatabase->is_public = $this->isPublic;
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->serviceDatabase->human_name;
$this->description = $this->serviceDatabase->description;
$this->image = $this->serviceDatabase->image;
$this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false;
$this->publicPort = $this->serviceDatabase->public_port;
$this->isPublic = $this->serviceDatabase->is_public ?? false;
$this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false;
}
}
public function generateDockerCompose()
{
try {
@@ -64,6 +181,375 @@ class Index extends Component
}
}
// Database-specific methods
public function refreshFileStorages()
{
if ($this->serviceDatabase) {
$this->fileStorages = $this->serviceDatabase->fileStorages()->get();
}
}
public function deleteDatabase($password)
{
try {
$this->authorize('delete', $this->serviceDatabase);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->serviceDatabase->delete();
$this->dispatch('success', 'Database deleted.');
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveExclude()
{
try {
$this->authorize('update', $this->serviceDatabase);
$this->submitDatabase();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveLogDrain()
{
try {
$this->authorize('update', $this->serviceDatabase);
if (! $this->serviceDatabase->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->submitDatabase();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToApplication()
{
try {
$this->authorize('update', $this->serviceDatabase);
$service = $this->serviceDatabase->service;
$serviceDatabase = $this->serviceDatabase;
// Check if application with same name already exists
if ($service->applications()->where('name', $serviceDatabase->name)->exists()) {
throw new \Exception('An application with this name already exists.');
}
// Create new parameters removing database_uuid
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceDatabase) {
$service->applications()->create([
'name' => $serviceDatabase->name,
'human_name' => $serviceDatabase->human_name,
'description' => $serviceDatabase->description,
'exclude_from_status' => $serviceDatabase->exclude_from_status,
'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled,
'image' => $serviceDatabase->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceDatabase->delete();
});
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->serviceDatabase);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncDatabaseData(true);
if ($this->serviceDatabase->is_public) {
if (! str($this->serviceDatabase->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->serviceDatabase->is_public = false;
return;
}
StartDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitDatabase()
{
try {
$this->authorize('update', $this->serviceDatabase);
$this->validate();
$this->syncDatabaseData(true);
$this->serviceDatabase->save();
$this->serviceDatabase->refresh();
$this->syncDatabaseData(false);
updateCompose($this->serviceDatabase);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('generateDockerCompose');
}
}
// Application-specific methods
private function initializeApplicationProperties(): void
{
$this->requiredPort = $this->serviceApplication->getRequiredPort();
$this->syncApplicationData(false);
}
private function syncApplicationData(bool $toModel = false): void
{
if ($toModel) {
$this->serviceApplication->human_name = $this->humanName;
$this->serviceApplication->description = $this->description;
$this->serviceApplication->fqdn = $this->fqdn;
$this->serviceApplication->image = $this->image;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
} else {
$this->humanName = $this->serviceApplication->human_name;
$this->description = $this->serviceApplication->description;
$this->fqdn = $this->serviceApplication->fqdn;
$this->image = $this->serviceApplication->image;
$this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true);
}
}
public function instantSaveApplication()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->submitApplication();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveApplicationSettings()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveApplicationAdvanced()
{
try {
$this->authorize('update', $this->serviceApplication);
if (! $this->serviceApplication->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->syncApplicationData(true);
$this->serviceApplication->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteApplication($password)
{
try {
$this->authorize('delete', $this->serviceApplication);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->serviceApplication->delete();
$this->dispatch('success', 'Application deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToDatabase()
{
try {
$this->authorize('update', $this->serviceApplication);
$service = $this->serviceApplication->service;
$serviceApplication = $this->serviceApplication;
if ($service->databases()->where('name', $serviceApplication->name)->exists()) {
throw new \Exception('A database with this name already exists.');
}
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceApplication) {
$service->databases()->create([
'name' => $serviceApplication->name,
'human_name' => $serviceApplication->human_name,
'description' => $serviceApplication->description,
'exclude_from_status' => $serviceApplication->exclude_from_status,
'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled,
'image' => $serviceApplication->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceApplication->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
$this->submitApplication();
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submitApplication();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncApplicationData(false);
}
public function submitApplication()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
$this->syncApplicationData(true);
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->serviceApplication);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
return;
}
} else {
$this->forceSaveDomains = false;
}
if (! $this->forceRemovePort) {
$requiredPort = $this->serviceApplication->getRequiredPort();
if ($requiredPort !== null) {
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
$this->forceRemovePort = false;
}
$this->validate();
$this->serviceApplication->save();
$this->serviceApplication->refresh();
$this->syncApplicationData(false);
updateCompose($this->serviceApplication);
if (str($this->serviceApplication->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
} else {
! $warning && $this->dispatch('success', 'Service saved.');
}
$this->dispatch('generateDockerCompose');
} catch (\Throwable $e) {
$originalFqdn = $this->serviceApplication->getOriginal('fqdn');
if ($originalFqdn !== $this->serviceApplication->fqdn) {
$this->serviceApplication->fqdn = $originalFqdn;
$this->syncApplicationData(false);
}
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.index');
@@ -1,345 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
public ServiceApplication $application;
public $parameters;
public $docker_cleanup = true;
public $delete_volumes = true;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $humanName = null;
#[Validate(['nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['string', 'nullable'])]
public ?string $image = null;
#[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
#[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'fqdn' => 'nullable',
'image' => 'string|nullable',
'excludeFromStatus' => 'required|boolean',
'application.required_fqdn' => 'required|boolean',
'isLogDrainEnabled' => 'nullable|boolean',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveSettings()
{
try {
$this->authorize('update', $this->application);
// Save checkbox states without port validation
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveAdvanced()
{
try {
$this->authorize('update', $this->application);
if (! $this->application->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
// Sync component properties to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function delete($password)
{
try {
$this->authorize('delete', $this->application);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();
$this->dispatch('success', 'Application deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submit();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncData(); // Reset to original FQDN
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
} else {
// Sync from model
$this->humanName = $this->application->human_name;
$this->description = $this->application->description;
$this->fqdn = $this->application->fqdn;
$this->image = $this->application->image;
$this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true);
}
}
public function convertToDatabase()
{
try {
$this->authorize('update', $this->application);
$service = $this->application->service;
$serviceApplication = $this->application;
// Check if database with same name already exists
if ($service->databases()->where('name', $serviceApplication->name)->exists()) {
throw new \Exception('A database with this name already exists.');
}
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceApplication) {
$service->databases()->create([
'name' => $serviceApplication->name,
'human_name' => $serviceApplication->human_name,
'description' => $serviceApplication->description,
'exclude_from_status' => $serviceApplication->exclude_from_status,
'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled,
'image' => $serviceApplication->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceApplication->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
$this->submit();
}
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check (without validation)
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
} else {
! $warning && $this->dispatch('success', 'Service saved.');
}
$this->dispatch('generateDockerCompose');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncData();
}
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.service-application-view', [
'checkboxes' => [
['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')],
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
// ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'],
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'],
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.']
],
]);
}
}
@@ -52,8 +52,6 @@ class StackForm extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.',
'dockerCompose.required' => 'The Docker Compose field is required.',
]
+1 -1
View File
@@ -111,7 +111,7 @@ class Danger extends Component
$this->docker_cleanup
);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);
+1 -1
View File
@@ -97,7 +97,7 @@ class Destination extends Component
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
'application_uuid' => data_get($this->resource, 'uuid'),
'deployment_uuid' => $deployment_uuid,
@@ -63,20 +63,30 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
if ($this->is_env_sorting_enabled === false) {
return $this->resource->environment_variables()->orderBy('order')->get();
$query = $this->resource->environment_variables()
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order');
}
return $this->resource->environment_variables;
return $query->get();
}
public function getEnvironmentVariablesPreviewProperty()
{
if ($this->is_env_sorting_enabled === false) {
return $this->resource->environment_variables_preview()->orderBy('order')->get();
$query = $this->resource->environment_variables_preview()
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order');
}
return $this->resource->environment_variables_preview;
return $query->get();
}
public function getDevView()
+85 -3
View File
@@ -21,6 +21,10 @@ use Livewire\Component;
class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = '';
public string $errors = '';
@@ -123,6 +127,9 @@ class GetLogs extends Component
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
$this->numberOfLines = 1000;
}
if ($this->numberOfLines > self::MAX_LOG_LINES) {
$this->numberOfLines = self::MAX_LOG_LINES;
}
if ($this->container) {
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
@@ -159,10 +166,12 @@ class GetLogs extends Component
}
// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
$newOutputs = '';
Process::run($sshCommand, function (string $type, string $output) use (&$newOutputs) {
$newOutputs .= removeAnsiColors($output);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$logChunks[] = removeAnsiColors($output);
});
$newOutputs = implode('', $logChunks);
if ($this->showTimeStamps) {
$newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
@@ -183,6 +192,79 @@ class GetLogs extends Component
return sanitizeLogsForExport($this->outputs);
}
public function downloadAllLogs(): string
{
if (! $this->server->isFunctional() || ! $this->container) {
return '';
}
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
$command = "docker service logs -t {$this->container}";
} else {
$command = "docker logs -t {$this->container}";
}
} else {
if ($this->server->isSwarm()) {
$command = "docker service logs {$this->container}";
} else {
$command = "docker logs {$this->container}";
}
}
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
// Enforce 50MB size limit to prevent memory exhaustion from large logs
$logChunks = [];
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$output = removeAnsiColors($output);
$outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
}
$truncated = true;
return;
}
$logChunks[] = $output;
$accumulatedBytes += $outputBytes;
});
$allLogs = implode('', $logChunks);
if ($truncated) {
$allLogs .= "\n\n[... Output truncated at 50MB limit ...]";
}
if ($this->showTimeStamps) {
$allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a);
$b = explode(' ', $b);
return $a[0] <=> $b[0];
})->join("\n");
}
return sanitizeLogsForExport($allLogs);
}
public function render()
{
return view('livewire.project.shared.get-logs');
+8 -1
View File
@@ -57,7 +57,14 @@ class Terminal extends Component
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
// Add sudo for non-root users to access Docker socket
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
if ($server->isNonRoot()) {
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
+2 -2
View File
@@ -48,7 +48,7 @@ class Show extends Component
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]);
@@ -59,7 +59,7 @@ class Show extends Component
public function navigateToEnvironment($projectUuid, $environmentUuid)
{
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
]);
+2 -2
View File
@@ -114,7 +114,7 @@ class Create extends Component
private function redirectAfterCreation(PrivateKey $privateKey)
{
return $this->from === 'server'
? redirect()->route('dashboard')
: redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
? redirectRoute($this, 'dashboard')
: redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
}
}
+1 -3
View File
@@ -40,8 +40,6 @@ class Show extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'privateKeyValue.required' => 'The Private Key field is required.',
'privateKeyValue.string' => 'The Private Key must be a valid string.',
]
@@ -107,7 +105,7 @@ class Show extends Component
$this->private_key->safeDelete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirect()->route('security.private-key.index');
return redirectRoute($this, 'security.private-key.index');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
+1 -1
View File
@@ -46,7 +46,7 @@ class Delete extends Component
$this->server->team_id
);
return redirect()->route('server.index');
return redirectRoute($this, 'server.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
+2 -2
View File
@@ -567,10 +567,10 @@ class ByHetzner extends Component
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
return redirectRoute($this, 'server.show', [$server->uuid]);
}
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1 -1
View File
@@ -128,7 +128,7 @@ class ByIp extends Component
$server->settings->is_build_server = $this->is_build_server;
$server->settings->save();
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
-6
View File
@@ -3,16 +3,12 @@
namespace App\Livewire\Settings;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Rules\ValidIpOrCidr;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Advanced extends Component
{
#[Validate('required')]
public Server $server;
public InstanceSettings $settings;
#[Validate('boolean')]
@@ -44,7 +40,6 @@ class Advanced extends Component
public function rules()
{
return [
'server' => 'required',
'is_registration_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
@@ -62,7 +57,6 @@ class Advanced extends Component
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->server = Server::findOrFail(0);
$this->settings = instanceSettings();
$this->custom_dns_servers = $this->settings->custom_dns_servers;
$this->allowed_ips = $this->settings->allowed_ips;
+23 -7
View File
@@ -12,7 +12,7 @@ class Index extends Component
{
public InstanceSettings $settings;
public Server $server;
public ?Server $server = null;
#[Validate('nullable|string|max:255')]
public ?string $fqdn = null;
@@ -26,10 +26,10 @@ class Index extends Component
#[Validate('nullable|string|max:255')]
public ?string $instance_name = null;
#[Validate('nullable|string')]
#[Validate('nullable|ipv4')]
public ?string $public_ipv4 = null;
#[Validate('nullable|string')]
#[Validate('nullable|ipv6')]
public ?string $public_ipv6 = null;
#[Validate('required|string|timezone')]
@@ -57,7 +57,9 @@ class Index extends Component
return redirect()->route('dashboard');
}
$this->settings = instanceSettings();
$this->server = Server::findOrFail(0);
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
$this->fqdn = $this->settings->fqdn;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
@@ -80,7 +82,7 @@ class Index extends Component
public function instantSave($isSave = true)
{
$this->validate();
$this->settings->fqdn = $this->fqdn;
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name;
@@ -119,9 +121,15 @@ class Index extends Component
return;
}
// Trim FQDN to remove leading/trailing whitespace before validation
if ($this->fqdn) {
$this->fqdn = trim($this->fqdn);
}
$this->validate();
if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
if (! validateDNSEntry($this->fqdn, $this->server)) {
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$error_show = true;
@@ -145,7 +153,9 @@ class Index extends Component
$this->instantSave(isSave: false);
$this->settings->save();
$this->server->setupDynamicProxyConfiguration();
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
if (! $error_show) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
@@ -163,6 +173,12 @@ class Index extends Component
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not available.');
return;
}
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');
+7 -3
View File
@@ -12,7 +12,7 @@ class Updates extends Component
{
public InstanceSettings $settings;
public Server $server;
public ?Server $server = null;
#[Validate('string')]
public string $auto_update_frequency;
@@ -25,7 +25,9 @@ class Updates extends Component
public function mount()
{
$this->server = Server::findOrFail(0);
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
$this->settings = instanceSettings();
$this->auto_update_frequency = $this->settings->auto_update_frequency;
@@ -76,7 +78,9 @@ class Updates extends Component
}
$this->instantSave();
$this->server->setupDynamicProxyConfiguration();
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
} catch (\Exception $e) {
return handleError($e, $this);
}
+1 -1
View File
@@ -58,7 +58,7 @@ class Create extends Component
session(['from' => session('from') + ['source_id' => $github_app->id]]);
}
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1 -1
View File
@@ -116,7 +116,7 @@ class Create extends Component
$this->storage->testConnection();
$this->storage->save();
return redirect()->route('storage.show', $this->storage->uuid);
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// return handleError($e, $this);
-2
View File
@@ -50,8 +50,6 @@ class Form extends Component
return array_merge(
ValidationPatterns::combinedMessages(),
[
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'region.required' => 'The Region field is required.',
'region.max' => 'The Region may not be greater than 255 characters.',
'key.required' => 'The Access Key field is required.',
+1 -1
View File
@@ -37,7 +37,7 @@ class Create extends Component
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession($team);
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
-2
View File
@@ -37,8 +37,6 @@ class Index extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
]
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ class InviteLink extends Component
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
if (is_null($userRole) || ($userRole === 'member' && in_array($this->role, ['admin', 'owner']))) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {
+3 -3
View File
@@ -71,11 +71,11 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam());
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::remember('team:'.$this->member->id, 3600, function () {
return $this->member->teams()->first();
});
Cache::forget("user:{$this->member->id}:team:{$teamId}");
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
+15 -62
View File
@@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -111,7 +112,7 @@ use Visus\Cuid2\Cuid2;
class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@@ -844,15 +845,7 @@ class Application extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->orderByRaw("
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
");
->where('is_preview', false);
}
public function runtime_environment_variables()
@@ -1584,6 +1577,11 @@ class Application extends BaseModel
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
} catch (\Exception $e) {
// Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
if (str($e->getMessage())->contains('No such file')) {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
@@ -1595,9 +1593,7 @@ class Application extends BaseModel
}
throw new \RuntimeException($e->getMessage());
} finally {
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
// Cleanup only - restoration happens in catch block
$commands = collect([
"rm -rf /tmp/{$uuid}",
]);
@@ -1643,6 +1639,11 @@ class Application extends BaseModel
'initialDockerComposeLocation' => $this->docker_compose_location,
];
} else {
// Restore original values before throwing
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
@@ -1658,7 +1659,7 @@ class Application extends BaseModel
$this->custom_labels = base64_encode($customLabels);
}
$customLabels = base64_decode($this->custom_labels);
if (mb_detect_encoding($customLabels, 'ASCII', true) === false) {
if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) {
$customLabels = str(implode('|coolify|', generateLabelsApplication($this, $preview)))->replace('|coolify|', "\n");
}
$this->custom_labels = base64_encode($customLabels);
@@ -1969,54 +1970,6 @@ class Application extends BaseModel
return [];
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
if ($server->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
if ($server->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
}
public function getLimits(): array
{
return [
@@ -41,6 +41,10 @@ class ApplicationDeploymentQueue extends Model
{
protected $guarded = [];
protected $casts = [
'finished_at' => 'datetime',
];
public function application()
{
return $this->belongsTo(Application::class);
-6
View File
@@ -15,12 +15,6 @@ use Visus\Cuid2\Cuid2;
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'environments' => new OA\Property(
property: 'environments',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Environment'),
description: 'The environments of the project.'
),
]
)]
class Project extends BaseModel
+2 -55
View File
@@ -16,6 +16,7 @@ use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -103,7 +104,7 @@ use Visus\Cuid2\Cuid2;
class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
public static $batch_counter = 0;
@@ -287,15 +288,6 @@ class Server extends BaseModel
return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false);
}
public static function destinationsByServer(string $server_id)
{
$server = Server::ownedByCurrentTeam()->get()->where('id', $server_id)->firstOrFail();
$standaloneDocker = collect($server->standaloneDockers->all());
$swarmDocker = collect($server->swarmDockers->all());
return $standaloneDocker->concat($swarmDocker);
}
public function settings()
{
return $this->hasOne(ServerSetting::class);
@@ -685,51 +677,6 @@ $schema://$host {
CheckAndStartSentinelJob::dispatch($this);
}
public function getCpuMetrics(int $mins = 5)
{
if ($this->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false);
if (str($cpu)->contains('error')) {
$error = json_decode($cpu, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$cpu = json_decode($cpu, true);
return collect($cpu)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
}
}
public function getMemoryMetrics(int $mins = 5)
{
if ($this->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false);
if (str($memory)->contains('error')) {
$error = json_decode($memory, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$memory = json_decode($memory, true);
$parsedCollection = collect($memory)->map(function ($metric) {
$usedPercent = $metric['usedPercent'] ?? 0.0;
return [(int) $metric['time'], (float) $usedPercent];
});
return $parsedCollection->toArray();
}
}
public function getDiskUsage(): ?string
{
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);
+75 -9
View File
@@ -515,6 +515,31 @@ class Service extends BaseModel
}
$fields->put('RabbitMQ', $data->toArray());
break;
case $image->is('registry'):
$data = collect([]);
$registry_user = $this->environment_variables()->where('key', 'SERVICE_USER_REGISTRY')->first();
$registry_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_REGISTRY')->first();
if ($registry_user) {
$data = $data->merge([
'Registry User' => [
'key' => data_get($registry_user, 'key'),
'value' => data_get($registry_user, 'value'),
'rules' => 'required',
],
]);
}
if ($registry_password) {
$data = $data->merge([
'Registry Password' => [
'key' => data_get($registry_password, 'key'),
'value' => data_get($registry_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Docker Registry', $data->toArray());
break;
case $image->contains('tolgee'):
$data = collect([]);
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_TOLGEE')->first();
@@ -844,6 +869,30 @@ class Service extends BaseModel
}
$fields->put('Meilisearch', $data->toArray());
break;
case $image->contains('linkding'):
$data = collect([]);
$SERVICE_USER_LINKDING = $this->environment_variables()->where('key', 'SERVICE_USER_LINKDING')->first();
$SERVICE_PASSWORD_LINKDING = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_LINKDING')->first();
if ($SERVICE_USER_LINKDING) {
$data = $data->merge([
'Superuser Name' => [
'key' => data_get($SERVICE_USER_LINKDING, 'key'),
'value' => data_get($SERVICE_USER_LINKDING, 'value'),
],
]);
}
if ($SERVICE_PASSWORD_LINKDING) {
$data = $data->merge([
'Superuser Password' => [
'key' => data_get($SERVICE_PASSWORD_LINKDING, 'key'),
'value' => data_get($SERVICE_PASSWORD_LINKDING, 'value'),
'isPassword' => true,
],
]);
}
$fields->put('Linkding', $data->toArray());
break;
case $image->contains('ghost'):
$data = collect([]);
$MAIL_OPTIONS_AUTH_PASS = $this->environment_variables()->where('key', 'MAIL_OPTIONS_AUTH_PASS')->first();
@@ -1044,6 +1093,31 @@ class Service extends BaseModel
$fields->put('Strapi', $data->toArray());
break;
case $image->contains('marckohlbrugge/sessy'):
$data = collect([]);
$username = $this->environment_variables()->where('key', 'SERVICE_USER_SESSY')->first();
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_SESSY')->first();
if ($username) {
$data = $data->merge([
'HTTP Auth Username' => [
'key' => data_get($username, 'key'),
'value' => data_get($username, 'value'),
'rules' => 'required',
],
]);
}
if ($password) {
$data = $data->merge([
'HTTP Auth Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Sessy', $data->toArray());
break;
default:
$data = collect([]);
$admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first();
@@ -1408,15 +1482,7 @@ class Service extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function workdir()
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -294,15 +295,7 @@ class StandaloneClickhouse extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function runtime_environment_variables()
@@ -320,50 +313,6 @@ class StandaloneClickhouse extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -316,50 +317,6 @@ class StandaloneDragonfly extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;
@@ -367,14 +324,6 @@ class StandaloneDragonfly extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -316,50 +317,6 @@ class StandaloneKeydb extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;
@@ -367,14 +324,6 @@ class StandaloneKeydb extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -288,15 +289,7 @@ class StandaloneMariadb extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function runtime_environment_variables()
@@ -319,50 +312,6 @@ class StandaloneMariadb extends BaseModel
return $this->morphMany(SslCertificate::class, 'resource');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return true;
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -341,50 +342,6 @@ class StandaloneMongodb extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return true;
@@ -392,14 +349,6 @@ class StandaloneMongodb extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -320,50 +321,6 @@ class StandaloneMysql extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return true;
@@ -371,14 +328,6 @@ class StandaloneMysql extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}
+3 -57
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -322,66 +323,11 @@ class StandalonePostgresql extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function isBackupSolutionAvailable()
{
return true;
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [
(int) $metric['time'],
(float) ($metric['percent'] ?? 0.0),
];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
}
+3 -54
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -332,50 +333,6 @@ class StandaloneRedis extends BaseModel
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;
@@ -417,14 +374,6 @@ class StandaloneRedis extends BaseModel
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}
+43 -21
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Jobs\UpdateStripeCustomerEmailJob;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
use App\Traits\DeletesUserSessions;
@@ -295,9 +296,10 @@ class User extends Authenticatable implements SendsEmail
public function isInstanceAdmin()
{
$found_root_team = Auth::user()->teams->filter(function ($team) {
$found_root_team = $this->teams->filter(function ($team) {
if ($team->id == 0) {
if (! Auth::user()->isAdmin()) {
$role = $team->pivot->role;
if ($role !== 'admin' && $role !== 'owner') {
return false;
}
@@ -310,32 +312,51 @@ class User extends Authenticatable implements SendsEmail
return $found_root_team->count() > 0;
}
public function currentTeam()
public function currentTeam(): ?Team
{
return Cache::remember('team:'.Auth::id(), 3600, function () {
if (is_null(data_get(session('currentTeam'), 'id')) && Auth::user()->teams->count() > 0) {
return Auth::user()->teams[0];
}
$sessionTeamId = data_get(session('currentTeam'), 'id');
return Team::find(session('currentTeam')->id);
if (is_null($sessionTeamId)) {
return null;
}
// Check if user actually belongs to this team
if (! $this->teams->contains('id', $sessionTeamId)) {
session()->forget('currentTeam');
Cache::forget('user:'.$this->id.':team:'.$sessionTeamId);
return null;
}
return Cache::remember('user:'.$this->id.':team:'.$sessionTeamId, 3600, function () use ($sessionTeamId) {
return Team::find($sessionTeamId);
});
}
public function otherTeams()
{
return Auth::user()->teams->filter(function ($team) {
return $team->id != currentTeam()->id;
});
}
public function role()
public function role(): ?string
{
if (data_get($this, 'pivot')) {
return $this->pivot->role;
}
$user = Auth::user()->teams->where('id', currentTeam()->id)->first();
return data_get($user, 'pivot.role');
$current = $this->currentTeam();
if (is_null($current)) {
return null;
}
$team = $this->teams->where('id', $current->id)->first();
return data_get($team, 'pivot.role');
}
/**
* Get the user's role in a specific team
*/
public function roleInTeam(int $teamId): ?string
{
$team = $this->teams->where('id', $teamId)->first();
return data_get($team, 'pivot.role');
}
/**
@@ -415,9 +436,10 @@ class User extends Authenticatable implements SendsEmail
]);
// For cloud users, dispatch job to update Stripe customer email asynchronously
if (isCloud() && $this->currentTeam()->subscription) {
dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob(
$this->currentTeam(),
$currentTeam = $this->currentTeam();
if (isCloud() && $currentTeam?->subscription) {
dispatch(new UpdateStripeCustomerEmailJob(
$currentTeam,
$this->id,
$newEmail,
$oldEmail
+1 -1
View File
@@ -287,7 +287,7 @@ class ChangelogService
$html = preg_replace('/<a([^>]*)>/', '<a$1 class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">', $html);
// Convert plain URLs to clickable links (that aren't already in <a> tags)
$html = preg_replace('/(?<!href="|href=\')(?<!>)(?<!\/)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/', '<a href="$1" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
$html = preg_replace('/(?<!href="|href=\')(?<!src="|src=\')(?<!>)(?<!\/)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/', '<a href="$1" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
// Strong/bold text
$html = preg_replace('/<strong[^>]*>/', '<strong class="font-semibold dark:text-white">', $html);
+8 -10
View File
@@ -8,16 +8,14 @@ namespace App\Support;
class ValidationPatterns
{
/**
* Pattern for names (allows letters, numbers, spaces, dashes, underscores, dots, slashes, colons, parentheses)
* Matches CleanupNames::sanitizeName() allowed characters
*/
public const NAME_PATTERN = '/^[a-zA-Z0-9\s\-_.:\/()]+$/';
* Pattern for names excluding all dangerous characters
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.]+$/u';
/**
* Pattern for descriptions (allows more characters including quotes, commas, etc.)
* More permissive than names but still restricts dangerous characters
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
*/
public const DESCRIPTION_PATTERN = '/^[a-zA-Z0-9\s\-_.:\/()\'\",.!?@#%&+=\[\]{}|~`*]+$/';
public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*]+$/u';
/**
* Get validation rules for name fields
@@ -66,7 +64,7 @@ class ValidationPatterns
public static function nameMessages(): array
{
return [
'name.regex' => 'The name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, dashes (-), underscores (_) and dots (.).",
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
@@ -78,12 +76,12 @@ class ValidationPatterns
public static function descriptionMessages(): array
{
return [
'description.regex' => 'The description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'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.',
];
}
/**
/**
* Get combined validation messages for both name and description fields
*/
public static function combinedMessages(): array
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Traits;
trait HasMetrics
{
public function getCpuMetrics(int $mins = 5): ?array
{
return $this->getMetrics('cpu', $mins, 'percent');
}
public function getMemoryMetrics(int $mins = 5): ?array
{
$field = $this->isServerMetrics() ? 'usedPercent' : 'used';
return $this->getMetrics('memory', $mins, $field);
}
private function getMetrics(string $type, int $mins, string $valueField): ?array
{
$server = $this->getMetricsServer();
if (! $server->isMetricsEnabled()) {
return null;
}
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
$response = instant_remote_process(
["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"],
$server,
false
);
if (str($response)->contains('error')) {
$error = json_decode($response, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) {
return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)];
})->toArray();
if ($mins > 60 && count($metrics) > 1000) {
$metrics = downsampleLTTB($metrics, 1000);
}
return $metrics;
}
private function isServerMetrics(): bool
{
return $this instanceof \App\Models\Server;
}
private function getMetricsServer(): \App\Models\Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}
private function getMetricsEndpoint(string $type, string $from): string
{
$base = 'http://localhost:8888/api';
if ($this->isServerMetrics()) {
return "{$base}/{$type}/history?from={$from}";
}
return "{$base}/container/{$this->uuid}/{$type}/history?from={$from}";
}
}