Merge branch 'next' into shadow/fix-typo-slash-proxy-page

This commit is contained in:
Andras Bacsai
2025-09-22 09:49:59 +02:00
committed by GitHub
30 changed files with 1680 additions and 377 deletions

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationConfigurationChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public function __construct($teamId = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus;
use App\Events\ApplicationConfigurationChanged;
use App\Events\ServiceStatusChanged;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@@ -147,6 +148,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private Collection $saved_outputs;
private ?string $secrets_hash_key = null;
private ?string $full_healthcheck_url = null;
private string $serverUser = 'root';
@@ -606,6 +609,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"),
'hidden' => true,
]);
// Modify Dockerfiles for ARGs and build secrets
$this->modify_dockerfiles_for_compose($composeFile);
// Build new container to limit downtime.
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
@@ -632,6 +638,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
}
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
}
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
);
@@ -2702,22 +2715,28 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if ($this->application->build_pack === 'nixpacks') {
$variables = collect($this->nixpacks_plan_json->get('variables'));
} else {
// Generate environment variables for build process (filters by is_buildtime = true)
$this->generate_env_variables();
$variables = collect([])->merge($this->env_args);
}
// Check if build secrets are enabled and BuildKit is supported
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
// Fall back to traditional build args
$secrets_hash = '';
if ($variables->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($variables);
}
$this->build_args = $variables->map(function ($value, $key) {
$value = escapeshellarg($value);
return "--build-arg {$key}={$value}";
});
if ($secrets_hash) {
$this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
}
}
@@ -2736,13 +2755,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return '';
}
return $variables
$secrets_hash = $this->generate_secrets_hash($variables);
$env_flags = $variables
->map(function ($env) {
$escaped_value = escapeshellarg($env->real_value);
return "-e {$env->key}={$escaped_value}";
})
->implode(' ');
$env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}";
return $env_flags;
}
private function generate_build_secrets(Collection $variables)
@@ -2758,6 +2782,36 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return "--secret id={$key},env={$key}";
})
->implode(' ');
$this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
}
private function generate_secrets_hash($variables)
{
if (! $this->secrets_hash_key) {
$this->secrets_hash_key = bin2hex(random_bytes(32));
}
if ($variables instanceof Collection) {
$secrets_string = $variables
->mapWithKeys(function ($value, $key) {
return [$key => $value];
})
->sortKeys()
->map(function ($value, $key) {
return "{$key}={$value}";
})
->implode('|');
} else {
$secrets_string = $variables
->map(function ($env) {
return "{$env->key}={$env->real_value}";
})
->sort()
->implode('|');
}
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
}
private function add_build_env_variables_to_dockerfile()
@@ -2799,6 +2853,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
}
if ($envs->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($envs);
$dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
@@ -2830,8 +2890,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// Get environment variables for secrets
$variables = $this->pull_request_id === 0
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get()
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
if ($variables->isEmpty()) {
return;
@@ -2840,6 +2900,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// Generate mount strings for all secrets
$mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$modified = false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line);
@@ -2868,6 +2931,164 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
private function modify_dockerfiles_for_compose($composeFile)
{
if ($this->application->build_pack !== 'dockercompose') {
return;
}
$variables = $this->pull_request_id === 0
? $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
->get()
: $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
->get();
if ($variables->isEmpty()) {
$this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.');
return;
}
$services = data_get($composeFile, 'services', []);
foreach ($services as $serviceName => $service) {
if (! isset($service['build'])) {
continue;
}
$context = '.';
$dockerfile = 'Dockerfile';
if (is_string($service['build'])) {
$context = $service['build'];
} elseif (is_array($service['build'])) {
$context = data_get($service['build'], 'context', '.');
$dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile');
}
$dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/');
if (str_starts_with($dockerfilePath, './')) {
$dockerfilePath = substr($dockerfilePath, 2);
}
if (str_starts_with($dockerfilePath, '/')) {
$dockerfilePath = substr($dockerfilePath, 1);
}
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"),
'hidden' => true,
'save' => 'dockerfile_check_'.$serviceName,
]);
if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') {
$this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection.");
continue;
}
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"),
'hidden' => true,
'save' => 'dockerfile_content_'.$serviceName,
]);
$dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName);
if (! $dockerfileContent) {
continue;
}
$dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n"));
$fromIndices = [];
$dockerfile_lines->each(function ($line, $index) use (&$fromIndices) {
if (str($line)->trim()->startsWith('FROM')) {
$fromIndices[] = $index;
}
});
if (empty($fromIndices)) {
$this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping.");
continue;
}
$isMultiStage = count($fromIndices) > 1;
$argsToAdd = collect([]);
foreach ($variables as $env) {
$argsToAdd->push("ARG {$env->key}");
}
ray($argsToAdd);
if ($argsToAdd->isEmpty()) {
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");
continue;
}
$totalAdded = 0;
$offset = 0;
foreach ($fromIndices as $stageIndex => $fromIndex) {
$adjustedIndex = $fromIndex + $offset;
$stageStart = $adjustedIndex + 1;
$stageEnd = isset($fromIndices[$stageIndex + 1])
? $fromIndices[$stageIndex + 1] + $offset
: $dockerfile_lines->count();
$existingStageArgs = collect([]);
for ($i = $stageStart; $i < $stageEnd; $i++) {
$line = $dockerfile_lines->get($i);
if (! $line || ! str($line)->trim()->startsWith('ARG')) {
break;
}
$parts = explode(' ', trim($line), 2);
if (count($parts) >= 2) {
$argPart = $parts[1];
$keyValue = explode('=', $argPart, 2);
$existingStageArgs->push($keyValue[0]);
}
}
$stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) {
$key = str($arg)->after('ARG ')->trim()->toString();
return ! $existingStageArgs->contains($key);
});
if ($stageArgsToAdd->isNotEmpty()) {
$dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray());
$totalAdded += $stageArgsToAdd->count();
$offset += $stageArgsToAdd->count();
}
}
if ($totalAdded > 0) {
$dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n"));
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"),
'hidden' => true,
]);
$stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : '';
$this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}.");
} else {
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
}
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
$fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
}
}
}
private function add_build_secrets_to_compose($composeFile)
{
// Get environment variables for secrets
@@ -3018,6 +3239,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
queue_next_deployment($this->application);
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
ray($this->application->team()->id);
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}

View File

@@ -0,0 +1,372 @@
<?php
namespace App\Livewire;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class GlobalSearch extends Component
{
public $searchQuery = '';
public $isModalOpen = false;
public $searchResults = [];
public $allSearchableItems = [];
public function mount()
{
$this->searchQuery = '';
$this->isModalOpen = false;
$this->searchResults = [];
$this->allSearchableItems = [];
}
public function openSearchModal()
{
$this->isModalOpen = true;
$this->loadSearchableItems();
$this->dispatch('search-modal-opened');
}
public function closeSearchModal()
{
$this->isModalOpen = false;
$this->searchQuery = '';
$this->searchResults = [];
}
public static function getCacheKey($teamId)
{
return 'global_search_items_'.$teamId;
}
public static function clearTeamCache($teamId)
{
Cache::forget(self::getCacheKey($teamId));
}
public function updatedSearchQuery()
{
$this->search();
}
private function loadSearchableItems()
{
// Try to get from Redis cache first
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
ray()->showQueries();
$items = collect();
$team = auth()->user()->currentTeam();
// Get all applications
$applications = Application::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($app) {
// Collect all FQDNs from the application
$fqdns = collect([]);
// For regular applications
if ($app->fqdn) {
$fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
}
// For docker compose based applications
if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
try {
$composeDomains = json_decode($app->docker_compose_domains, true);
if (is_array($composeDomains)) {
foreach ($composeDomains as $serviceName => $domains) {
if (is_array($domains)) {
$fqdns = $fqdns->merge($domains);
}
}
}
} catch (\Exception $e) {
// Ignore JSON parsing errors
}
}
$fqdnsString = $fqdns->implode(' ');
return [
'id' => $app->id,
'name' => $app->name,
'type' => 'application',
'uuid' => $app->uuid,
'description' => $app->description,
'link' => $app->link(),
'project' => $app->environment->project->name ?? null,
'environment' => $app->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
];
});
// Get all services
$services = Service::ownedByCurrentTeam()
->with(['environment.project', 'applications'])
->get()
->map(function ($service) {
// Collect all FQDNs from service applications
$fqdns = collect([]);
foreach ($service->applications as $app) {
if ($app->fqdn) {
$appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
$fqdns = $fqdns->merge($appFqdns);
}
}
$fqdnsString = $fqdns->implode(' ');
return [
'id' => $service->id,
'name' => $service->name,
'type' => 'service',
'uuid' => $service->uuid,
'description' => $service->description,
'link' => $service->link(),
'project' => $service->environment->project->name ?? null,
'environment' => $service->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
];
});
// Get all standalone databases
$databases = collect();
// PostgreSQL
$databases = $databases->merge(
StandalonePostgresql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'postgresql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' postgresql '.$db->description),
];
})
);
// MySQL
$databases = $databases->merge(
StandaloneMysql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mysql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mysql '.$db->description),
];
})
);
// MariaDB
$databases = $databases->merge(
StandaloneMariadb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mariadb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mariadb '.$db->description),
];
})
);
// MongoDB
$databases = $databases->merge(
StandaloneMongodb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mongodb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mongodb '.$db->description),
];
})
);
// Redis
$databases = $databases->merge(
StandaloneRedis::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'redis',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' redis '.$db->description),
];
})
);
// KeyDB
$databases = $databases->merge(
StandaloneKeydb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'keydb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' keydb '.$db->description),
];
})
);
// Dragonfly
$databases = $databases->merge(
StandaloneDragonfly::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'dragonfly',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' dragonfly '.$db->description),
];
})
);
// Clickhouse
$databases = $databases->merge(
StandaloneClickhouse::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'clickhouse',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' clickhouse '.$db->description),
];
})
);
// Get all servers
$servers = Server::ownedByCurrentTeam()
->get()
->map(function ($server) {
return [
'id' => $server->id,
'name' => $server->name,
'type' => 'server',
'uuid' => $server->uuid,
'description' => $server->description,
'link' => $server->url(),
'project' => null,
'environment' => null,
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
];
});
// Merge all collections
$items = $items->merge($applications)
->merge($services)
->merge($databases)
->merge($servers);
return $items->toArray();
});
}
private function search()
{
if (strlen($this->searchQuery) < 2) {
$this->searchResults = [];
return;
}
$query = strtolower($this->searchQuery);
// Case-insensitive search in the items
$this->searchResults = collect($this->allSearchableItems)
->filter(function ($item) use ($query) {
return str_contains($item['search_text'], $query);
})
->take(20)
->values()
->toArray();
}
public function render()
{
return view('livewire.global-search');
}
}

View File

@@ -20,7 +20,15 @@ class ConfigurationChecker extends Component
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
protected $listeners = ['configurationChanged'];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
'configurationChanged' => 'configurationChanged',
];
}
public function mount()
{

View File

@@ -8,7 +8,7 @@ class Metrics extends Component
{
public $resource;
public $chartId = 'container-cpu';
public $chartId = 'metrics';
public $data;

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -110,7 +111,7 @@ use Visus\Cuid2\Cuid2;
class Application extends BaseModel
{
use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@@ -123,66 +124,6 @@ class Application extends BaseModel
'http_basic_auth_password' => 'encrypted',
];
public function customNetworkAliases(): Attribute
{
return Attribute::make(
set: function ($value) {
if (is_null($value) || $value === '') {
return null;
}
// If it's already a JSON string, decode it
if (is_string($value) && $this->isJson($value)) {
$value = json_decode($value, true);
}
// If it's a string but not JSON, treat it as a comma-separated list
if (is_string($value) && ! is_array($value)) {
$value = explode(',', $value);
}
$value = collect($value)
->map(function ($alias) {
if (is_string($alias)) {
return str_replace(' ', '-', trim($alias));
}
return null;
})
->filter()
->unique() // Remove duplicate values
->values()
->toArray();
return empty($value) ? null : json_encode($value);
},
get: function ($value) {
if (is_null($value)) {
return null;
}
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
return is_array($value) ? $value : [];
}
);
}
/**
* Check if a string is a valid JSON
*/
private function isJson($string)
{
if (! is_string($string)) {
return false;
}
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
protected static function booted()
{
static::addGlobalScope('withRelations', function ($builder) {
@@ -250,6 +191,66 @@ class Application extends BaseModel
});
}
public function customNetworkAliases(): Attribute
{
return Attribute::make(
set: function ($value) {
if (is_null($value) || $value === '') {
return null;
}
// If it's already a JSON string, decode it
if (is_string($value) && $this->isJson($value)) {
$value = json_decode($value, true);
}
// If it's a string but not JSON, treat it as a comma-separated list
if (is_string($value) && ! is_array($value)) {
$value = explode(',', $value);
}
$value = collect($value)
->map(function ($alias) {
if (is_string($alias)) {
return str_replace(' ', '-', trim($alias));
}
return null;
})
->filter()
->unique() // Remove duplicate values
->values()
->toArray();
return empty($value) ? null : json_encode($value);
},
get: function ($value) {
if (is_null($value)) {
return null;
}
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
return is_array($value) ? $value : [];
}
);
}
/**
* Check if a string is a valid JSON
*/
private function isJson($string)
{
if (! is_string($string)) {
return false;
}
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');

View File

@@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model
return str($this->commit_message)->value();
}
private function redactSensitiveInfo($text)
{
$text = remove_iip($text);
$app = $this->application;
if (! $app) {
return $text;
}
$lockedVars = collect([]);
if ($app->environment_variables) {
$lockedVars = $lockedVars->merge(
$app->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
$lockedVars = $lockedVars->merge(
$app->environment_variables_preview
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
{
if ($type === 'error') {
@@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model
}
$newLogEntry = [
'command' => null,
'output' => remove_iip($message),
'output' => $this->redactSensitiveInfo($message),
'type' => $type,
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,

View File

@@ -13,6 +13,7 @@ use App\Jobs\RegenerateSslCertJob;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -55,7 +56,7 @@ use Visus\Cuid2\Cuid2;
class Server extends BaseModel
{
use HasFactory, SchemalessAttributesTrait, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
public static $batch_counter = 0;

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ProcessStatus;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -41,7 +42,7 @@ use Visus\Cuid2\Cuid2;
)]
class Service extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -43,6 +44,11 @@ class StandaloneClickhouse extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -43,6 +44,11 @@ class StandaloneDragonfly extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -43,6 +44,11 @@ class StandaloneKeydb extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
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 StandaloneMariadb extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -44,6 +45,11 @@ class StandaloneMariadb extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -46,6 +47,11 @@ class StandaloneMongodb extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -44,6 +45,11 @@ class StandaloneMysql extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -44,6 +45,11 @@ class StandalonePostgresql extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
public function workdir()
{
return database_configuration_dir()."/{$this->uuid}";

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -45,6 +46,11 @@ class StandaloneRedis extends BaseModel
});
}
public static function ownedByCurrentTeam()
{
return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Traits;
use App\Livewire\GlobalSearch;
trait ClearsGlobalSearchCache
{
protected static function bootClearsGlobalSearchCache()
{
static::saving(function ($model) {
// Only clear cache if searchable fields are being changed
if ($model->hasSearchableChanges()) {
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
}
});
static::created(function ($model) {
// Always clear cache when model is created
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
});
static::deleted(function ($model) {
// Always clear cache when model is deleted
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
});
}
private function hasSearchableChanges(): bool
{
// Define searchable fields based on model type
$searchableFields = ['name', 'description'];
// Add model-specific searchable fields
if ($this instanceof \App\Models\Application) {
$searchableFields[] = 'fqdn';
$searchableFields[] = 'docker_compose_domains';
} elseif ($this instanceof \App\Models\Server) {
$searchableFields[] = 'ip';
} elseif ($this instanceof \App\Models\Service) {
// Services don't have direct fqdn, but name and description are covered
}
// Database models only have name and description as searchable
// Check if any searchable field is dirty
foreach ($searchableFields as $field) {
if ($this->isDirty($field)) {
return true;
}
}
return false;
}
private function getTeamIdForCache()
{
// For database models, team is accessed through environment.project.team
if (method_exists($this, 'team')) {
$team = $this->team();
if (filled($team)) {
return is_object($team) ? $team->id : null;
}
}
// For models with direct team_id property
if (property_exists($this, 'team_id') || isset($this->team_id)) {
return $this->team_id;
}
return null;
}
}

View File

@@ -17,6 +17,46 @@ trait ExecuteRemoteCommand
public static int $batch_counter = 0;
private function redact_sensitive_info($text)
{
$text = remove_iip($text);
if (! isset($this->application)) {
return $text;
}
$lockedVars = collect([]);
if (isset($this->application->environment_variables)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables_preview
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function execute_remote_command(...$commands)
{
static::$batch_counter++;
@@ -74,7 +114,7 @@ trait ExecuteRemoteCommand
// Track SSH retry event in Sentry
$this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
'command' => remove_iip($command),
'command' => $this->redact_sensitive_info($command),
'trait' => 'ExecuteRemoteCommand',
]);
@@ -125,8 +165,8 @@ trait ExecuteRemoteCommand
$sanitized_output = sanitize_utf8_text($output);
$new_log_entry = [
'command' => remove_iip($command),
'output' => remove_iip($sanitized_output),
'command' => $this->redact_sensitive_info($command),
'output' => $this->redact_sensitive_info($sanitized_output),
'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
@@ -194,7 +234,7 @@ trait ExecuteRemoteCommand
$retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
$new_log_entry = [
'output' => remove_iip($retryMessage),
'output' => $this->redact_sensitive_info($retryMessage),
'type' => 'stdout',
'timestamp' => Carbon::now('UTC'),
'hidden' => false,