Merge remote-tracking branch 'origin/next' into fix/deploy-key-dedicated-path-race

This commit is contained in:
Andras Bacsai committed 2026-06-03 14:00:34 +02:00
commit 3eb5463e3e
108 files changed
+3323 -891

No files matched your search

+12 -7
View File
@@ -13,7 +13,7 @@ class StopApplication
public string $jobQueue = 'high';
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
{
$servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) {
@@ -57,12 +57,17 @@ class StopApplication
}
}
// Reset restart tracking when application is manually stopped
$application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
if ($resetRestartCount) {
$application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
} else {
$application->update([
'status' => 'exited',
]);
}
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
+15 -11
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Docker;
use App\Actions\Application\StopApplication;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck;
@@ -9,6 +10,7 @@ use App\Events\ServiceChecked;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr;
@@ -464,7 +466,9 @@ class GetContainersStatus
}
// Wrap all database updates in a transaction to ensure consistency
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
$restartLimitReached = false;
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
$previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) {
@@ -475,16 +479,10 @@ class GetContainersStatus
'last_restart_type' => 'crash',
]);
// Send notification
$containerName = $application->name;
$projectUuid = data_get($application, 'environment.project.uuid');
$environmentName = data_get($application, 'environment.name');
$applicationUuid = data_get($application, 'uuid');
if ($projectUuid && $applicationUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
} else {
$url = null;
// Check if restart limit has been reached
$maxAllowedRestarts = $application->max_restart_count ?? 0;
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
$restartLimitReached = true;
}
}
@@ -499,6 +497,12 @@ class GetContainersStatus
}
}
});
if ($restartLimitReached) {
$application->refresh();
StopApplication::dispatch($application, false, true, false);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
/**
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
* plaintext JSON rows written before the column was encrypted, so existing
* snapshots keep decoding instead of throwing.
*
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
*/
class EncryptedArrayCast implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
* @return array<mixed>|null
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
{
if ($value === null || $value === '') {
return null;
}
try {
$value = Crypt::decryptString($value);
} catch (DecryptException) {
// Legacy plaintext JSON written before this column was encrypted.
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
}
}
+1 -1
View File
@@ -253,7 +253,7 @@ class Init extends Command
'save_s3' => false,
'frequency' => '0 0 * * *',
'database_id' => $database->id,
'database_type' => \App\Models\StandalonePostgresql::class,
'database_type' => StandalonePostgresql::class,
'team_id' => 0,
]);
}
@@ -146,7 +146,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -312,7 +312,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -478,7 +478,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -781,7 +781,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -1024,7 +1024,7 @@ class ApplicationsController extends Controller
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1230,7 +1230,7 @@ class ApplicationsController extends Controller
'git_repository' => 'string|required',
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -1470,7 +1470,7 @@ class ApplicationsController extends Controller
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -1793,7 +1793,7 @@ class ApplicationsController extends Controller
$validationRules = [
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
+26 -10
View File
@@ -13,6 +13,7 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -487,10 +488,12 @@ class ServersController extends Controller
'ip' => ['string', 'required', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
], [
...ValidationPatterns::serverUsernameMessages(),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -666,7 +669,7 @@ class ServersController extends Controller
'ip' => ['string', 'nullable', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
@@ -676,6 +679,8 @@ class ServersController extends Controller
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300',
], [
...ValidationPatterns::serverUsernameMessages(),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -700,17 +705,17 @@ class ServersController extends Controller
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower();
});
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
$server->changeProxy($request->proxy_type, async: true);
} else {
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
return response()->json(['message' => 'Invalid proxy type.'], 422);
}
}
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
if ($request->is_build_server) {
$server->settings()->update([
'is_build_server' => $request->is_build_server,
]);
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
if ($request->filled('private_key_uuid')) {
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$updateFields['private_key_id'] = $privateKey->id;
}
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
@@ -720,11 +725,22 @@ class ServersController extends Controller
], 422);
}
$server->update($updateFields);
if ($request->has('is_build_server')) {
$server->settings()->update([
'is_build_server' => $request->boolean('is_build_server'),
]);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
if ($request->proxy_type) {
$server->changeProxy($request->proxy_type, async: true);
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
+44 -11
View File
@@ -11,6 +11,8 @@ use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@@ -539,19 +541,22 @@ class Github extends Controller
public function install(Request $request)
{
$source = (string) $request->query('source', '');
abort_if(blank($source), 404);
$github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail();
$setup_action = (string) $request->query('setup_action', '');
if ($setup_action !== 'install') {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
if ($setup_action === 'update') {
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'install',
);
abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id),
403,
@@ -564,6 +569,19 @@ class Github extends Controller
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
{
$github_app = GithubApp::ownedByCurrentTeam()
->where('installation_id', $installation_id)
->first();
if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
return redirect()->route('source.all');
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
@@ -596,11 +614,14 @@ class Github extends Controller
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
abort_if(blank($state), 404);
if (blank($state)) {
$this->rejectInvalidGithubAppSetupState($request);
}
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
abort_unless(is_array($payload), 404);
abort_unless(data_get($payload, 'action') === $action, 404);
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
$this->rejectInvalidGithubAppSetupState($request);
}
$team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
@@ -610,6 +631,18 @@ class Github extends Controller
->firstOrFail();
}
private function rejectInvalidGithubAppSetupState(Request $request): never
{
if ($request->expectsJson()) {
abort(404);
}
throw new HttpResponseException(
redirect()
->route('source.all')
);
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
+20 -13
View File
@@ -1388,7 +1388,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
$envs->push("PORT={$ports[0]}");
}
}
@@ -3139,7 +3139,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
'image' => $this->production_image_name,
'container_name' => $this->container_name,
'restart' => RESTART_MODE,
'expose' => $ports,
...(! empty($ports) ? ['expose' => $ports] : []),
'networks' => [
$this->destination->network => [
'aliases' => array_merge(
@@ -3171,16 +3171,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
// If healthcheck is disabled, no healthcheck will be added
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
$this->generate_healthcheck_commands(),
],
'interval' => $this->application->health_check_interval.'s',
'timeout' => $this->application->health_check_timeout.'s',
'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
$healthcheck_command = $this->generate_healthcheck_commands();
if ($healthcheck_command !== null) {
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
$healthcheck_command,
],
'interval' => $this->application->health_check_interval.'s',
'timeout' => $this->application->health_check_timeout.'s',
'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
}
}
if (! is_null($this->application->limits_cpuset)) {
@@ -3390,7 +3393,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
$health_check_port = (int) $this->application->ports_exposes_array[0];
if (! empty($this->application->ports_exposes_array)) {
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
return null;
}
} else {
$health_check_port = (int) $this->application->health_check_port;
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -44,7 +45,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
{
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]]
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
+23 -10
View File
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
@@ -212,6 +213,23 @@ class Index extends Component
}
}
protected function rules(): array
{
return [
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
];
}
protected function messages(): array
{
return [
...ValidationPatterns::serverUsernameMessages('remoteServerUser', 'SSH User'),
];
}
public function getProxyType()
{
$this->selectProxy(ProxyTypes::TRAEFIK->value);
@@ -274,12 +292,7 @@ class Index extends Component
public function saveServer()
{
$this->validate([
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer',
'remoteServerUser' => 'required|string',
]);
$this->validate();
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
@@ -465,10 +478,10 @@ class Index extends Component
public function saveAndValidateServer()
{
$this->validate([
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => 'required|string',
]);
$this->validate(array_intersect_key($this->rules(), array_flip([
'remoteServerPort',
'remoteServerUser',
])));
$this->createdServer->update([
'port' => $this->remoteServerPort,
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Livewire\Destination;
use App\Models\Application;
use App\Models\BaseModel;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
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\Contracts\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Resources extends Component
{
#[Locked]
public $destination;
public array $resources = [];
public function mount(string $destination_uuid)
{
try {
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('destination.index');
}
if (! $destination instanceof StandaloneDocker) {
return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]);
}
$this->destination = $destination;
$this->loadResources();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Load applications, services, and database resources deployed to the standalone Docker destination.
*
* @return void Populates the resources property for display.
*/
public function loadResources(): void
{
$this->resources = $this->collectResources([
$this->destination->applications,
$this->destination->services,
$this->destination->postgresqls,
$this->destination->redis,
$this->destination->mongodbs,
$this->destination->mysqls,
$this->destination->mariadbs,
$this->destination->keydbs,
$this->destination->dragonflies,
$this->destination->clickhouses,
]);
}
/**
* @param array<int, iterable<Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse>> $groups
* @return array<int, array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}>
*/
protected function collectResources(array $groups): array
{
$rows = [];
foreach ($groups as $group) {
foreach ($group as $resource) {
$rows[] = $this->resourceRow($resource);
}
}
return $rows;
}
/**
* @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource
* @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}
*/
protected function resourceRow(BaseModel $resource): array
{
$type = match (true) {
$resource instanceof Application => 'application',
$resource instanceof Service => 'service',
default => 'database',
};
$environment = $resource->environment;
$project = $environment?->project;
$routeName = "project.{$type}.configuration";
$url = ($project && $environment)
? route($routeName, [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
"{$type}_uuid" => $resource->uuid,
])
: null;
return [
'uuid' => $resource->uuid,
'type' => $type,
'name' => $resource->name,
'project' => $project?->name,
'environment' => $environment?->name,
'url' => $url,
'search' => strtolower(implode(' ', array_filter([
$type,
$resource->name,
$project?->name,
$environment?->name,
]))),
];
}
public function render(): View
{
return view('livewire.destination.resources');
}
}
@@ -87,6 +87,9 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $isConnectToDockerNetworkEnabled = false;
#[Validate(['integer', 'min:0'])]
public int $maxRestartCount = 10;
public function mount()
{
try {
@@ -149,6 +152,7 @@ class Advanced extends Component
$this->disableBuildCache = $this->application->settings->disable_build_cache;
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
$this->maxRestartCount = $this->application->max_restart_count ?? 10;
}
// Load stop_grace_period separately since it has its own save handler
@@ -289,6 +293,21 @@ class Advanced extends Component
}
}
public function saveMaxRestartCount()
{
try {
$this->authorize('update', $this->application);
$this->validate([
'maxRestartCount' => 'integer|min:0',
]);
$this->application->max_restart_count = $this->maxRestartCount;
$this->application->save();
$this->dispatch('success', 'Max restart count saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.application.advanced');
@@ -28,7 +28,7 @@ class Configuration extends Component
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
+2 -3
View File
@@ -154,7 +154,7 @@ class General extends Component
'staticImage' => 'required',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'],
'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
@@ -212,7 +212,6 @@ class General extends Component
'buildPack.required' => 'The Build Pack field is required.',
'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory field is required.',
'portsExposes.required' => 'The Exposed Ports field is required.',
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
...ValidationPatterns::portMappingMessages(),
'isStatic.required' => 'The Static setting is required.',
@@ -760,7 +759,7 @@ class General extends Component
$this->resetErrorBag();
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString();
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
if ($this->portsMappings) {
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
}
@@ -26,7 +26,7 @@ class Configuration extends Component
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -44,7 +44,7 @@ class Configuration extends Component
$this->query = request()->query();
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -21,8 +21,6 @@ class ConfigurationChecker extends Component
public array $configurationDiff = [];
public array $groupedConfigurationChanges = [];
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
public function getListeners(): array
@@ -50,21 +48,56 @@ class ConfigurationChecker extends Component
$this->configurationChanged();
}
/**
* Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser.
*
* @param array<int, array<string, mixed>> $changes
* @return array<int, array<string, mixed>>
*/
private function redactEnvironmentChanges(array $changes, bool $redact): array
{
if (! $redact) {
return $changes;
}
return collect($changes)
->map(function (array $change): array {
if (data_get($change, 'section') !== 'environment') {
return $change;
}
$change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
$change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
$change['old_full_value'] = null;
$change['new_full_value'] = null;
$change['expandable'] = false;
$change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
return $change;
})
->all();
}
public function configurationChanged(): void
{
$this->resource->refresh();
if ($this->resource instanceof Application) {
$diff = $this->resource->pendingDeploymentConfigurationDiff();
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array = $diff->toArray();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->isConfigurationChanged = $diff->isChanged();
$this->configurationDiff = $diff->toArray();
$this->groupedConfigurationChanges = $diff->groupedChanges();
$this->configurationDiff = $array;
return;
}
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
$this->configurationDiff = [];
$this->groupedConfigurationChanges = [];
}
}
@@ -7,6 +7,8 @@ use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Component;
class All extends Component
@@ -25,6 +27,8 @@ class All extends Component
public string $view = 'normal';
public string $search = '';
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
@@ -35,6 +39,20 @@ class All extends Component
'environmentVariableDeleted' => 'refreshEnvs',
];
public function updatedSearch(): void
{
$this->clearEnvironmentVariableCaches();
}
private function clearEnvironmentVariableCaches(): void
{
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
unset($this->hardcodedEnvironmentVariables);
unset($this->hardcodedEnvironmentVariablesPreview);
unset($this->hasEnvironmentVariables);
}
public function mount()
{
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
@@ -65,8 +83,27 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
$query = $this->resource->environment_variables()
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
return $this->getEnvironmentVariables(false);
}
public function getEnvironmentVariablesPreviewProperty()
{
return $this->getEnvironmentVariables(true);
}
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
{
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($withSearch && $this->searchTerm() !== '') {
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
}
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
@@ -77,18 +114,22 @@ class All extends Component
return $query->get();
}
public function getEnvironmentVariablesPreviewProperty()
private function searchTerm(): string
{
$query = $this->resource->environment_variables_preview()
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
return trim($this->search);
}
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order');
}
public function getHasEnvironmentVariablesProperty(): bool
{
return $this->environmentVariables->isNotEmpty() ||
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
}
return $query->get();
public function getIsSearchActiveProperty(): bool
{
return $this->searchTerm() !== '';
}
public function getHardcodedEnvironmentVariablesProperty()
@@ -138,6 +179,12 @@ class All extends Component
return ! in_array($var['key'], $managedKeys);
});
if ($this->searchTerm() !== '') {
$hardcodedVars = $hardcodedVars->filter(function ($var) {
return str($var['key'])->contains($this->searchTerm(), true);
});
}
// Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
@@ -149,9 +196,9 @@ class All extends Component
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
$this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false));
if ($this->showPreview) {
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
$this->variablesPreview = $this->formatEnvironmentVariables($this->getEnvironmentVariables(true, false));
}
}
@@ -282,9 +329,7 @@ class All extends Component
$environment->order = $maxOrder + 1;
$environment->save();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->clearEnvironmentVariableCaches();
$this->dispatch('success', 'Environment variable added.');
}
@@ -413,9 +458,7 @@ class All extends Component
public function refreshEnvs()
{
$this->resource->refresh();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->clearEnvironmentVariableCaches();
$this->getDevView();
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class ResourceDetails extends Component
{
use AuthorizesRequests;
public $resource;
public ?string $project_uuid = null;
public ?string $project_name = null;
public ?string $environment_uuid = null;
public ?string $environment_name = null;
public ?string $server_uuid = null;
public ?string $server_name = null;
public array $stack_applications = [];
public array $stack_databases = [];
public function mount()
{
$this->authorize('view', $this->resource);
$environment = $this->resource->environment ?? null;
if ($environment) {
$this->environment_uuid = $environment->uuid;
$this->environment_name = $environment->name;
$project = $environment->project ?? null;
if ($project) {
$this->project_uuid = $project->uuid;
$this->project_name = $project->name;
}
}
$server = $this->resolveServer();
if ($server) {
$this->server_uuid = $server->uuid;
$this->server_name = $server->name;
}
if ($this->resource instanceof Service) {
$this->stack_applications = $this->resource->applications
->map(fn ($app) => [
'name' => $app->human_name ?: $app->name,
'uuid' => $app->uuid,
])
->values()
->all();
$this->stack_databases = $this->resource->databases
->map(fn ($db) => [
'name' => $db->human_name ?: $db->name,
'uuid' => $db->uuid,
])
->values()
->all();
}
}
private function resolveServer()
{
try {
if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
return $this->resource->destination->server;
}
if (method_exists($this->resource, 'server') && $this->resource->server) {
return $this->resource->server;
}
} catch (\Throwable $e) {
return null;
}
return null;
}
public function render()
{
return view('livewire.project.shared.resource-details');
}
}
+27
View File
@@ -2,11 +2,15 @@
namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Charts extends Component
{
use AuthorizesRequests;
public Server $server;
public $chartId = 'server';
@@ -28,6 +32,29 @@ class Charts extends Component
}
}
public function toggleMetrics(): void
{
try {
$this->authorize('update', $this->server);
$this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled;
$this->server->settings->save();
$this->server->refresh();
if ($this->server->isMetricsEnabled()) {
StartSentinel::run($this->server, true);
$this->dispatch('success', 'Metrics enabled. Starting Sentinel.');
$this->dispatch('refreshServerShow');
$this->redirect(route('server.metrics', ['server_uuid' => $this->server->uuid]), navigate: true);
} else {
$this->server->restartSentinel();
$this->dispatch('success', 'Metrics disabled. Restarting Sentinel.');
$this->dispatch('refreshServerShow');
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function pollData()
{
if ($this->poll || $this->interval <= 10) {
+2 -1
View File
@@ -57,7 +57,7 @@ class ByIp extends Component
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', 'string', new ValidServerIp],
'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
'is_build_server' => 'required|boolean',
];
@@ -75,6 +75,7 @@ class ByIp extends Component
'ip.string' => 'The IP Address/Domain must be a string.',
'user.required' => 'The User field is required.',
'user.string' => 'The User field must be a string.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.',
'port.integer' => 'The Port field must be an integer.',
'port.between' => 'The Port field must be between 1 and 65535.',
+8 -14
View File
@@ -15,8 +15,6 @@ class Sentinel extends Component
public Server $server;
public array $parameters = [];
public bool $isMetricsEnabled;
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
@@ -51,15 +49,9 @@ class Sentinel extends Component
];
}
public function mount(string $server_uuid)
public function mount()
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
$this->syncData();
}
public function syncData(bool $toModel = false)
@@ -112,27 +104,29 @@ class Sentinel extends Component
}
}
public function updatedIsSentinelEnabled($value)
public function toggleSentinel(): void
{
try {
$this->authorize('manageSentinel', $this->server);
if ($value === true) {
if (! $this->isSentinelEnabled) {
if ($this->server->isBuildServer()) {
$this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
$this->isSentinelEnabled = true;
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage);
} else {
$this->isSentinelEnabled = false;
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;
StopSentinel::dispatch($this->server);
}
$this->submit();
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
handleError($e, $this);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
use Illuminate\View\View;
use Livewire\Component;
class Logs extends Component
{
public ?Server $server = null;
public array $parameters = [];
public function mount(): void
{
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.server.sentinel.logs');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
use Illuminate\View\View;
use Livewire\Component;
class Show extends Component
{
public ?Server $server = null;
public array $parameters = [];
public function mount(): void
{
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.server.sentinel.show');
}
}
+2 -1
View File
@@ -110,7 +110,7 @@ class Show extends Component
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
@@ -140,6 +140,7 @@ class Show extends Component
[
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.',
'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+47 -9
View File
@@ -8,6 +8,15 @@ use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
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 App\Services\SchedulerLogParser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -125,7 +134,21 @@ class ScheduledJobs extends Component
: collect();
$backups = $backupIds->isNotEmpty()
? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id')
? ScheduledDatabaseBackup::with('database')
->whereIn('id', $backupIds)
->get()
->loadMorph('database', [
ServiceDatabase::class => ['service.environment.project'],
StandaloneClickhouse::class => ['environment.project'],
StandaloneDragonfly::class => ['environment.project'],
StandaloneKeydb::class => ['environment.project'],
StandaloneMariadb::class => ['environment.project'],
StandaloneMongodb::class => ['environment.project'],
StandaloneMysql::class => ['environment.project'],
StandalonePostgresql::class => ['environment.project'],
StandaloneRedis::class => ['environment.project'],
])
->keyBy('id')
: collect();
$servers = $serverIds->isNotEmpty()
@@ -161,14 +184,29 @@ class ScheduledJobs extends Component
if ($backup) {
$database = $backup->database;
$skip['resource_name'] = $database?->name ?? 'Database backup';
$environment = $database?->environment;
$project = $environment?->project;
if ($project && $environment && $database) {
$skip['link'] = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
if ($database instanceof ServiceDatabase) {
$service = $database->service;
$environment = $service?->environment;
$project = $environment?->project;
if ($project && $environment && $service) {
$skip['link'] = route('project.service.database.backups', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
]);
}
} else {
$environment = $database?->environment;
$project = $environment?->project;
if ($project && $environment && $database) {
$skip['link'] = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
}
}
}
} elseif ($skip['type'] === 'docker_cleanup') {
+3
View File
@@ -210,6 +210,9 @@ class Change extends Component
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false);
$this->name = str($this->github_app->name)->kebab();
$this->dispatch('success', 'Github App permissions updated.');
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats
+62 -13
View File
@@ -204,6 +204,7 @@ class Application extends BaseModel
'config_hash',
'last_online_at',
'restart_count',
'max_restart_count',
'last_restart_at',
'last_restart_type',
'uuid',
@@ -227,6 +228,7 @@ class Application extends BaseModel
'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted',
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
}
@@ -570,6 +572,15 @@ class Application extends BaseModel
return null;
}
public function stoppedAfterRestartLimit(): bool
{
return str($this->status)->startsWith('exited')
&& ($this->restart_count ?? 0) > 0
&& ($this->max_restart_count ?? 0) > 0
&& $this->restart_count >= $this->max_restart_count
&& $this->last_restart_type === 'crash';
}
public function taskLink($task_uuid)
{
if (data_get($this, 'environment.project.uuid')) {
@@ -1509,6 +1520,28 @@ class Application extends BaseModel
}
}
private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string
{
return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1');
}
private function isHttpGitRepository(string $repository): bool
{
return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://');
}
private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string
{
$configuredCommand = preg_replace(
"/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/",
"git {$gitConfigOptions} clone",
$gitCloneCommand,
1
);
return $configuredCommand ?: $gitCloneCommand;
}
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
{
$branch = $this->git_branch;
@@ -1552,8 +1585,10 @@ class Application extends BaseModel
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$gitConfigOptions = $this->withGitHttpTransportConfig();
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@@ -1566,6 +1601,7 @@ class Application extends BaseModel
// Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials.
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/");
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
$git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command);
if ($exec_in_docker) {
@@ -1579,8 +1615,9 @@ class Application extends BaseModel
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$fullRepoUrl = $repoUrl;
}
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@@ -1591,12 +1628,13 @@ class Application extends BaseModel
if ($pull_request_id !== 0) {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions ?? null);
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
$escapedPrBranch = escapeshellarg($branch);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
} else {
$commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command");
$commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command");
}
}
@@ -1666,7 +1704,11 @@ class Application extends BaseModel
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
if ($gitConfigOptions) {
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
}
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@@ -1754,11 +1796,15 @@ class Application extends BaseModel
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
if ($gitConfigOptions) {
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
}
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
$otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
if ($pull_request_id !== 0) {
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
if ($git_type === 'gitlab') {
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1766,7 +1812,7 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1774,14 +1820,14 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions);
}
}
@@ -1880,7 +1926,8 @@ class Application extends BaseModel
return;
}
$uuid = new Cuid2;
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.');
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]);
@@ -1910,6 +1957,7 @@ class Application extends BaseModel
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'cd checkout',
'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
@@ -1921,6 +1969,7 @@ class Application extends BaseModel
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'cd checkout',
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
@@ -2353,7 +2402,7 @@ class Application extends BaseModel
'config.build_pack' => 'required|string',
'config.base_directory' => 'required|string',
'config.publish_directory' => 'required|string',
'config.ports_exposes' => 'required|string',
'config.ports_exposes' => 'nullable|string',
'config.settings.is_static' => 'required|boolean',
]);
if ($deepValidator->fails()) {
+16 -2
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Casts\EncryptedArrayCast;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -74,11 +75,24 @@ class ApplicationDeploymentQueue extends Model
'finished_at',
];
/**
* The configuration snapshot/diff hold full (decrypted on read) configuration,
* including unlocked environment variable values. They are only meant for the
* in-app diff modal (which redacts per role) and must never be serialized by the
* API, so hide them globally as defense in depth.
*
* @var array<int, string>
*/
protected $hidden = [
'configuration_snapshot',
'configuration_diff',
];
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
'configuration_snapshot' => 'array',
'configuration_diff' => 'array',
'configuration_snapshot' => EncryptedArrayCast::class,
'configuration_diff' => EncryptedArrayCast::class,
];
public function application()
+3 -2
View File
@@ -17,6 +17,7 @@ use App\Livewire\Server\Proxy;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -945,10 +946,10 @@ $schema://$host {
{
return Attribute::make(
get: function ($value) {
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
},
set: function ($value) {
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
}
);
}
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Jobs\ConnectProxyToNetworksJob;
use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StandaloneDocker extends BaseModel
@@ -127,7 +128,7 @@ class StandaloneDocker extends BaseModel
return $this->morphMany(Service::class, 'destination');
}
public function databases()
public function databases(): Collection
{
$postgresqls = $this->postgresqls;
$redis = $this->redis;
@@ -0,0 +1,141 @@
<?php
namespace App\Notifications\Application;
use App\Models\Application;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class RestartLimitReached extends CustomEmailNotification
{
public string $resource_name;
public string $project_uuid;
public string $environment_uuid;
public string $environment_name;
public ?string $resource_url = null;
public ?string $fqdn;
public int $restart_count;
public int $max_restart_count;
public function __construct(public Application $resource)
{
$this->onQueue('high');
$this->afterCommit();
$this->resource_name = data_get($resource, 'name');
$this->project_uuid = data_get($resource, 'environment.project.uuid');
$this->environment_uuid = data_get($resource, 'environment.uuid');
$this->environment_name = data_get($resource, 'environment.name');
$this->fqdn = data_get($resource, 'fqdn', null);
$this->restart_count = $resource->restart_count;
$this->max_restart_count = $resource->max_restart_count;
if (str($this->fqdn)->explode(',')->count() > 1) {
$this->fqdn = str($this->fqdn)->explode(',')->first();
}
$this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}";
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('status_change');
}
public function toMail(): MailMessage
{
$mail = new MailMessage;
$mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})");
$mail->view('emails.application-restart-limit-reached', [
'name' => $this->resource_name,
'fqdn' => $this->fqdn,
'resource_url' => $this->resource_url,
'restart_count' => $this->restart_count,
'max_restart_count' => $this->max_restart_count,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
return new DiscordMessage(
title: ':warning: Restart limit reached',
description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})",
color: DiscordMessage::errorColor(),
isCritical: true,
);
}
public function toTelegram(): array
{
$message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
return [
'message' => $message,
'buttons' => [
[
'text' => 'Open Application in Coolify',
'url' => $this->resource_url,
],
],
];
}
public function toPushover(): PushoverMessage
{
$message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
return new PushoverMessage(
title: 'Restart limit reached',
level: 'error',
message: $message,
buttons: [
[
'text' => 'Open Application in Coolify',
'url' => $this->resource_url,
],
],
);
}
public function toSlack(): SlackMessage
{
$title = 'Restart limit reached';
$description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})";
$description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name');
$description .= "\n*Environment:* {$this->environment_name}";
$description .= "\n*Application URL:* {$this->resource_url}";
return new SlackMessage(
title: $title,
description: $description,
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
return [
'success' => false,
'message' => 'Restart limit reached',
'event' => 'restart_limit_reached',
'application_name' => $this->resource_name,
'application_uuid' => $this->resource->uuid,
'restart_count' => $this->restart_count,
'max_restart_count' => $this->max_restart_count,
'url' => $this->resource_url,
'project' => data_get($this->resource, 'environment.project.name'),
'environment' => $this->environment_name,
'fqdn' => $this->fqdn,
];
}
}
@@ -4,10 +4,13 @@ namespace App\Services\DeploymentConfiguration;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
use Illuminate\Support\Arr;
class ApplicationConfigurationSnapshot
{
use SummarizesDiffText;
public const SCHEMA_VERSION = 1;
public function __construct(protected Application $application) {}
@@ -115,12 +118,14 @@ class ApplicationConfigurationSnapshot
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
$this->item('install_command', 'Install command', $this->application->install_command, 'build'),
$this->item('build_command', 'Build command', $this->application->build_command, 'build'),
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)),
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile),
$this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
$this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
$this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
$this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)),
$this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)),
// The generated docker_compose is intentionally excluded: it is re-rendered
// from git on every parse (resolved env, generated labels, deployment context),
// so comparing it would flag a permanent change for git-based compose apps.
$this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'),
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
@@ -162,9 +167,10 @@ class ApplicationConfigurationSnapshot
{
return [
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)),
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)),
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
@@ -234,6 +240,7 @@ class ApplicationConfigurationSnapshot
private function environmentItem(EnvironmentVariable $environmentVariable): array
{
$impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
$locked = (bool) $environmentVariable->is_shown_once;
$compareValue = [
'value_hash' => $this->sensitiveHash($environmentVariable->value),
'is_multiline' => $environmentVariable->is_multiline,
@@ -242,20 +249,62 @@ class ApplicationConfigurationSnapshot
'is_runtime' => $environmentVariable->is_runtime,
];
// Locked (is_shown_once) variables are always redacted and never store a value.
if ($locked) {
return $this->item(
key: (string) $environmentVariable->key,
label: (string) $environmentVariable->key,
value: $compareValue,
impact: $impact,
sensitive: true,
displayValue: $this->environmentDisplayValue($environmentVariable),
);
}
// Unlocked variables expose their value so owners/admins can see the change.
// The compare value is pre-hashed (identical formula to the locked branch) so
// change detection stays stable and never carries the raw value; members are
// redacted at render time in ConfigurationChecker; the column is encrypted at rest.
// The value and each scope flag are rendered as their own line and diffed by line,
// so a change to one or more attributes shows exactly what changed (one line each).
$value = (string) $environmentVariable->value;
return $this->item(
key: (string) $environmentVariable->key,
label: (string) $environmentVariable->key,
value: $compareValue,
value: $this->sensitiveHash($this->normalizeValue($compareValue)),
impact: $impact,
sensitive: true,
displayValue: $this->environmentDisplayValue($environmentVariable),
sensitive: false,
displayValue: $this->summarizeText($value),
displayFull: $this->environmentLines($environmentVariable),
diffMode: 'lines',
);
}
/**
* One line per attribute so the line diff surfaces exactly which value/flags changed.
*/
private function environmentLines(EnvironmentVariable $environmentVariable): string
{
$lines = collect();
$value = (string) $environmentVariable->value;
if (filled($value)) {
$lines->push($value);
}
$lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled'));
$lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled'));
$lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled'));
$lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled'));
return $lines->implode("\n");
}
/**
* @return array<string, mixed>
*/
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array
{
$normalizedValue = $this->normalizeValue($value);
@@ -264,21 +313,28 @@ class ApplicationConfigurationSnapshot
'label' => $label,
'impact' => $impact,
'sensitive' => $sensitive,
'diff_mode' => $diffMode,
'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)),
];
}
private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
{
$flags = collect([
$flags = $this->environmentFlags($environmentVariable);
return $flags ? "Hidden ({$flags})" : 'Hidden';
}
private function environmentFlags(EnvironmentVariable $environmentVariable): string
{
return collect([
$environmentVariable->is_buildtime ? 'build-time' : null,
$environmentVariable->is_runtime ? 'runtime' : null,
$environmentVariable->is_multiline ? 'multiline' : null,
$environmentVariable->is_literal ? 'literal' : null,
])->filter()->implode(', ');
return $flags ? "Hidden ({$flags})" : 'Hidden';
}
private function sensitiveHash(mixed $value): string
@@ -320,6 +376,58 @@ class ApplicationConfigurationSnapshot
return $this->summarizeText((string) $value);
}
private function stringifyValue(mixed $value): ?string
{
if ($value === null || is_bool($value)) {
return null;
}
if (is_array($value)) {
return json_encode($value, JSON_THROW_ON_ERROR);
}
return (string) $value;
}
/**
* @return array<string, mixed>|null
*/
private function decodedComposeDomains(): ?array
{
if (blank($this->application->docker_compose_domains)) {
return null;
}
$decoded = json_decode((string) $this->application->docker_compose_domains, true);
return is_array($decoded) ? $decoded : null;
}
private function composeDomainsText(): ?string
{
$decoded = $this->decodedComposeDomains();
if (blank($decoded)) {
return null;
}
return collect($decoded)
->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-'))
->sort()
->implode("\n");
}
private function decodeCustomLabels(?string $value): ?string
{
if (blank($value)) {
return null;
}
$decoded = base64_decode($value, true);
return $decoded === false ? $value : $decoded;
}
private function summarizeText(?string $value): string
{
if (blank($value)) {
@@ -333,6 +441,6 @@ class ApplicationConfigurationSnapshot
return str($value)->limit(80)." ({$lines} lines)";
}
return str($value)->limit(120)->value();
return str($value)->limit(self::SINGLE_LINE_LIMIT)->value();
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Services\DeploymentConfiguration\Concerns;
trait SummarizesDiffText
{
/**
* Maximum length of a single-line value before it is truncated/considered
* worth expanding. Kept as one constant so the snapshot summary and the
* differ's expand decision never drift apart.
*/
private const SINGLE_LINE_LIMIT = 120;
/**
* Returns the value only when it is worth expanding (multi-line or longer
* than the single-line truncation limit). Otherwise null.
*/
private function expandableText(?string $value): ?string
{
if (blank($value)) {
return null;
}
$value = trim((string) $value);
if (str_contains($value, "\n") || mb_strlen($value) > self::SINGLE_LINE_LIMIT) {
return $value;
}
return null;
}
}
@@ -2,8 +2,6 @@
namespace App\Services\DeploymentConfiguration;
use Illuminate\Support\Collection;
class ConfigurationDiff
{
/**
@@ -81,20 +79,6 @@ class ConfigurationDiff
return $this->changes;
}
/**
* @return array<string, array{label: string, changes: array<int, array<string, mixed>>}>
*/
public function groupedChanges(): array
{
return collect($this->changes)
->groupBy('section')
->map(fn (Collection $changes): array => [
'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()),
'changes' => $changes->values()->all(),
])
->all();
}
/**
* @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array<int, array<string, mixed>>}
*/
@@ -2,8 +2,21 @@
namespace App\Services\DeploymentConfiguration;
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
class ConfigurationDiffer
{
use SummarizesDiffText;
/**
* Keys that must never be reported as changes. The generated docker_compose
* is re-rendered from git on every parse, so legacy snapshots that still
* contain it would otherwise flag a permanent change after it was dropped.
*
* @var array<int, string>
*/
private const IGNORED_KEYS = ['build.docker_compose'];
/**
* @param array<string, mixed> $previousSnapshot
* @param array<string, mixed> $currentSnapshot
@@ -16,6 +29,10 @@ class ConfigurationDiffer
$changes = [];
foreach ($keys as $key) {
if (in_array($key, self::IGNORED_KEYS, true)) {
continue;
}
$previous = $previousItems[$key] ?? null;
$current = $currentItems[$key] ?? null;
@@ -27,6 +44,37 @@ class ConfigurationDiffer
$sensitive = (bool) data_get($item, 'sensitive', false);
$type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
$displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
$diffMode = data_get($item, 'diff_mode', 'default');
$oldFull = null;
$newFull = null;
if ($sensitive) {
$oldDisplay = $previous === null ? '-' : '••••••••';
$newDisplay = $current === null ? '-' : '••••••••';
} elseif ($diffMode === 'lines' && $type === 'changed') {
[$oldDisplay, $newDisplay] = $this->changedLines(
data_get($previous, 'display_full'),
data_get($current, 'display_full'),
);
// No line-level difference (e.g. only reordering) — fall back to the summary.
if ($oldDisplay === '-' && $newDisplay === '-') {
$oldDisplay = data_get($previous, 'display_value', '-');
$newDisplay = data_get($current, 'display_value', '-');
}
// Expansion reveals the full changed lines, not the entire value.
$oldFull = $this->expandableText($oldDisplay);
$newFull = $this->expandableText($newDisplay);
} else {
$oldDisplay = data_get($previous, 'display_value', '-');
$newDisplay = data_get($current, 'display_value', '-');
$oldFull = data_get($previous, 'display_full');
$newFull = data_get($current, 'display_full');
}
$expandable = ! $sensitive && (filled($oldFull) || filled($newFull));
$changes[] = [
'key' => $key,
@@ -37,14 +85,54 @@ class ConfigurationDiffer
'impact' => data_get($item, 'impact', 'redeploy'),
'sensitive' => $sensitive,
'display_summary' => $displaySummary,
'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'),
'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'),
'old_display_value' => $oldDisplay,
'new_display_value' => $newDisplay,
'old_full_value' => $oldFull,
'new_full_value' => $newFull,
'expandable' => $expandable,
];
}
return ConfigurationDiff::fromChanges($changes);
}
/**
* Reduce two multi-line values to only the lines that differ, so the modal
* shows just the changed container labels instead of the whole block.
*
* @return array{0: string, 1: string}
*/
private function changedLines(?string $old, ?string $new): array
{
$oldLines = $this->textLines($old);
$newLines = $this->textLines($new);
$removed = array_values(array_diff($oldLines, $newLines));
$added = array_values(array_diff($newLines, $oldLines));
return [
$removed === [] ? '-' : implode("\n", $removed),
$added === [] ? '-' : implode("\n", $added),
];
}
/**
* @return array<int, string>
*/
private function textLines(?string $value): array
{
if (blank($value)) {
return [];
}
// Keep leading indentation (meaningful for YAML/compose), drop trailing whitespace.
return collect(preg_split('/\r\n|\r|\n/', (string) $value))
->map(fn (string $line): string => rtrim($line))
->filter(fn (string $line): bool => trim($line) !== '')
->values()
->all();
}
/**
* @param array<string, mixed> $snapshot
* @return array<string, array<string, mixed>>
+33
View File
@@ -35,6 +35,17 @@ class ValidationPatterns
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for SSH usernames.
* Allows alphanumeric characters, dots, hyphens, and underscores.
*/
public const SERVER_USERNAME_PATTERN = '/^[a-zA-Z0-9._-]+$/';
/**
* Pattern for removing characters not allowed in SSH usernames.
*/
public const INVALID_SERVER_USERNAME_CHARACTERS_PATTERN = '/[^A-Za-z0-9.\-_]/';
/**
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
*
@@ -283,6 +294,28 @@ class ValidationPatterns
return $rules;
}
/**
* Get validation rules for SSH username fields.
*/
public static function serverUsernameRules(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'string',
'regex:'.self::SERVER_USERNAME_PATTERN,
];
}
/**
* Get validation messages for SSH username fields.
*/
public static function serverUsernameMessages(string $field = 'user', string $label = 'User'): array
{
return [
"{$field}.regex" => "The {$label} may only contain letters, numbers, dots, hyphens, and underscores.",
];
}
/**
* Get validation messages for database identifier fields.
*/