Merge branch 'next' into next
This commit is contained in:
commit
a03de180f5
87 changed files with 3444 additions and 1559 deletions
|
|
@ -9,6 +9,8 @@ alwaysApply: false
|
|||
|
||||
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
|
||||
|
||||
!Important: Always run tests inside `coolify` container.
|
||||
|
||||
## Testing Framework Stack
|
||||
|
||||
### Core Testing Tools
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ ## Reporting a Vulnerability
|
|||
If you discover a security vulnerability, please follow these steps:
|
||||
|
||||
1. **DO NOT** disclose the vulnerability publicly.
|
||||
2. Send a detailed report to: `hi@coollabs.io`.
|
||||
2. Send a detailed report to: `security@coollabs.io`.
|
||||
3. Include in your report:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ class StartMongodb
|
|||
|
||||
public string $configuration_dir;
|
||||
|
||||
public string $volume_configuration_dir;
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMongodb $database)
|
||||
|
|
@ -29,10 +27,7 @@ public function handle(StandaloneMongodb $database)
|
|||
$startCommand = 'mongod';
|
||||
|
||||
$container_name = $this->database->uuid;
|
||||
$this->volume_configuration_dir = $this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
if (isDev()) {
|
||||
$this->volume_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
|
||||
}
|
||||
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
|
||||
$this->commands = [
|
||||
"echo 'Starting database.'",
|
||||
|
|
@ -178,7 +173,7 @@ public function handle(StandaloneMongodb $database)
|
|||
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||
[[
|
||||
'type' => 'bind',
|
||||
'source' => $this->volume_configuration_dir.'/mongod.conf',
|
||||
'source' => $this->configuration_dir.'/mongod.conf',
|
||||
'target' => '/etc/mongo/mongod.conf',
|
||||
'read_only' => true,
|
||||
]]
|
||||
|
|
@ -192,7 +187,7 @@ public function handle(StandaloneMongodb $database)
|
|||
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||
[[
|
||||
'type' => 'bind',
|
||||
'source' => $this->volume_configuration_dir.'/docker-entrypoint-initdb.d',
|
||||
'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d',
|
||||
'target' => '/docker-entrypoint-initdb.d',
|
||||
'read_only' => true,
|
||||
]]
|
||||
|
|
@ -259,7 +254,7 @@ public function handle(StandaloneMongodb $database)
|
|||
$this->commands[] = [
|
||||
'transfer_file' => [
|
||||
'content' => $docker_compose,
|
||||
'destination' => "$this->volume_configuration_dir/docker-compose.yml",
|
||||
'destination' => "$this->configuration_dir/docker-compose.yml",
|
||||
],
|
||||
];
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ class StartPostgresql
|
|||
|
||||
public string $configuration_dir;
|
||||
|
||||
public string $volume_configuration_dir;
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandalonePostgresql $database)
|
||||
|
|
@ -29,10 +27,6 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->database = $database;
|
||||
$container_name = $this->database->uuid;
|
||||
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
$this->volume_configuration_dir = $this->configuration_dir;
|
||||
if (isDev()) {
|
||||
$this->volume_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
|
||||
}
|
||||
|
||||
$this->commands = [
|
||||
"echo 'Starting database.'",
|
||||
|
|
@ -195,7 +189,7 @@ public function handle(StandalonePostgresql $database)
|
|||
$docker_compose['services'][$container_name]['volumes'],
|
||||
[[
|
||||
'type' => 'bind',
|
||||
'source' => $this->volume_configuration_dir.'/custom-postgres.conf',
|
||||
'source' => $this->configuration_dir.'/custom-postgres.conf',
|
||||
'target' => '/etc/postgresql/postgresql.conf',
|
||||
'read_only' => true,
|
||||
]]
|
||||
|
|
@ -223,7 +217,7 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->commands[] = [
|
||||
'transfer_file' => [
|
||||
'content' => $docker_compose,
|
||||
'destination' => "$this->volume_configuration_dir/docker-compose.yml",
|
||||
'destination' => "$this->configuration_dir/docker-compose.yml",
|
||||
],
|
||||
];
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
|
|
@ -236,6 +230,8 @@ public function handle(StandalonePostgresql $database)
|
|||
}
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
ray($this->commands);
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class GetContainersStatus
|
|||
|
||||
public $server;
|
||||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -119,11 +121,16 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$foundApplications[] = $application->id;
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => $containerStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->applicationContainerStatuses)) {
|
||||
$this->applicationContainerStatuses = collect();
|
||||
}
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = data_get($labels, 'com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
// Notify user that this container should not be there.
|
||||
|
|
@ -320,6 +327,83 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
}
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
}
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
|
||||
{
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, don't update status
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aggregate status: if any container is running, app is running
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,268 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\ServerStorageCheckJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use Illuminate\Support\Arr;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ServerCheck
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public bool $isSentinel = false;
|
||||
|
||||
public $containers;
|
||||
|
||||
public $databases;
|
||||
|
||||
public function handle(Server $server, $data = null)
|
||||
{
|
||||
$this->server = $server;
|
||||
try {
|
||||
if ($this->server->isFunctional() === false) {
|
||||
return 'Server is not functional.';
|
||||
}
|
||||
|
||||
if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
|
||||
|
||||
if (isset($data)) {
|
||||
$data = collect($data);
|
||||
|
||||
$this->server->sentinelHeartbeat();
|
||||
|
||||
$this->containers = collect(data_get($data, 'containers'));
|
||||
|
||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
|
||||
$containerReplicates = null;
|
||||
$this->isSentinel = true;
|
||||
} else {
|
||||
['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
|
||||
// ServerStorageCheckJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if (is_null($this->containers)) {
|
||||
return 'No containers found.';
|
||||
}
|
||||
|
||||
if (isset($containerReplicates)) {
|
||||
foreach ($containerReplicates as $containerReplica) {
|
||||
$name = data_get($containerReplica, 'Name');
|
||||
$this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
|
||||
if (data_get($container, 'Spec.Name') === $name) {
|
||||
$replicas = data_get($containerReplica, 'Replicas');
|
||||
$running = str($replicas)->explode('/')[0];
|
||||
$total = str($replicas)->explode('/')[1];
|
||||
if ($running === $total) {
|
||||
data_set($container, 'State.Status', 'running');
|
||||
data_set($container, 'State.Health.Status', 'healthy');
|
||||
} else {
|
||||
data_set($container, 'State.Status', 'starting');
|
||||
data_set($container, 'State.Health.Status', 'unhealthy');
|
||||
}
|
||||
}
|
||||
|
||||
return $container;
|
||||
});
|
||||
}
|
||||
}
|
||||
$this->checkContainers();
|
||||
|
||||
if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
|
||||
$foundProxyContainer = $this->containers->filter(function ($value, $key) {
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
|
||||
} else {
|
||||
return data_get($value, 'Name') === '/coolify-proxy';
|
||||
}
|
||||
})->first();
|
||||
$proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
|
||||
if (! $foundProxyContainer || $proxyStatus !== 'running') {
|
||||
try {
|
||||
$shouldStart = CheckProxy::run($this->server);
|
||||
if ($shouldStart) {
|
||||
StartProxy::run($this->server, async: false);
|
||||
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
|
||||
$this->server->save();
|
||||
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
||||
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkLogDrainContainer()
|
||||
{
|
||||
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
|
||||
return data_get($value, 'Name') === '/coolify-log-drain';
|
||||
})->first();
|
||||
if ($foundLogDrainContainer) {
|
||||
$status = data_get($foundLogDrainContainer, 'State.Status');
|
||||
if ($status !== 'running') {
|
||||
StartLogDrain::dispatch($this->server);
|
||||
}
|
||||
} else {
|
||||
StartLogDrain::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkContainers()
|
||||
{
|
||||
foreach ($this->containers as $container) {
|
||||
if ($this->isSentinel) {
|
||||
$labels = Arr::undot(data_get($container, 'labels'));
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
$labels = Arr::undot(data_get($container, 'Spec.Labels'));
|
||||
} else {
|
||||
$labels = Arr::undot(data_get($container, 'Config.Labels'));
|
||||
}
|
||||
}
|
||||
$managed = data_get($labels, 'coolify.managed');
|
||||
if (! $managed) {
|
||||
continue;
|
||||
}
|
||||
$uuid = data_get($labels, 'coolify.name');
|
||||
if (! $uuid) {
|
||||
$uuid = data_get($labels, 'com.docker.compose.service');
|
||||
}
|
||||
|
||||
if ($this->isSentinel) {
|
||||
$containerStatus = data_get($container, 'state');
|
||||
$containerHealth = data_get($container, 'health_status');
|
||||
} else {
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
}
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
$serviceId = data_get($labels, 'coolify.serviceId');
|
||||
$databaseId = data_get($labels, 'coolify.databaseId');
|
||||
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
|
||||
|
||||
if ($applicationId) {
|
||||
// Application
|
||||
if ($pullRequestId != 0) {
|
||||
if (str($applicationId)->contains('-')) {
|
||||
$applicationId = str($applicationId)->before('-');
|
||||
}
|
||||
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
|
||||
if ($preview) {
|
||||
$preview->update(['status' => $containerStatus]);
|
||||
}
|
||||
} else {
|
||||
$application = Application::where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$application->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif (isset($serviceId)) {
|
||||
// Service
|
||||
$subType = data_get($labels, 'coolify.service.subType');
|
||||
$subId = data_get($labels, 'coolify.service.subId');
|
||||
$service = Service::where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$service = ServiceApplication::where('id', $subId)->first();
|
||||
} else {
|
||||
$service = ServiceDatabase::where('id', $subId)->first();
|
||||
}
|
||||
if ($service) {
|
||||
$service->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
if ($subType === 'database') {
|
||||
$isPublic = data_get($service, 'is_public');
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->isSentinel) {
|
||||
return data_get($value, 'name') === $uuid.'-proxy';
|
||||
} else {
|
||||
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
|
||||
} else {
|
||||
return data_get($value, 'Name') === "/$uuid-proxy";
|
||||
}
|
||||
}
|
||||
})->first();
|
||||
if (! $foundTcpProxy) {
|
||||
StartDatabaseProxy::run($service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Database
|
||||
if (is_null($this->databases)) {
|
||||
$this->databases = $this->server->databases();
|
||||
}
|
||||
$database = $this->databases->where('uuid', $uuid)->first();
|
||||
if ($database) {
|
||||
$database->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
|
||||
$isPublic = data_get($database, 'is_public');
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->isSentinel) {
|
||||
return data_get($value, 'name') === $uuid.'-proxy';
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
|
||||
} else {
|
||||
|
||||
return data_get($value, 'Name') === "/$uuid-proxy";
|
||||
}
|
||||
}
|
||||
})->first();
|
||||
if (! $foundTcpProxy) {
|
||||
StartDatabaseProxy::run($database);
|
||||
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ class StartSentinel
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null)
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
|
||||
{
|
||||
if ($server->isSwarm() || $server->isBuildServer()) {
|
||||
return;
|
||||
|
|
@ -44,7 +44,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
|
|||
];
|
||||
if (isDev()) {
|
||||
// data_set($environments, 'DEBUG', 'true');
|
||||
// $image = 'sentinel';
|
||||
if ($customImage && ! empty($customImage)) {
|
||||
$image = $customImage;
|
||||
}
|
||||
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
|
||||
}
|
||||
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
|
||||
|
|
|
|||
151
app/Actions/Stripe/CancelSubscription.php
Normal file
151
app/Actions/Stripe/CancelSubscription.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscription
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
private ?StripeClient $stripe = null;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
|
||||
if (! $isDryRun && isCloud()) {
|
||||
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
}
|
||||
|
||||
public function getSubscriptionsPreview(): Collection
|
||||
{
|
||||
$subscriptions = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only include subscriptions from teams where user is owner
|
||||
$userRole = $team->pivot->role;
|
||||
if ($userRole === 'owner' && $team->subscription) {
|
||||
$subscription = $team->subscription;
|
||||
|
||||
// Only include active subscriptions
|
||||
if ($subscription->stripe_subscription_id &&
|
||||
$subscription->stripe_invoice_paid) {
|
||||
$subscriptions->push($subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $subscriptions;
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'cancelled' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$cancelledCount = 0;
|
||||
$failedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
$subscriptions = $this->getSubscriptionsPreview();
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
$this->cancelSingleSubscription($subscription);
|
||||
$cancelledCount++;
|
||||
} catch (\Exception $e) {
|
||||
$failedCount++;
|
||||
$errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
|
||||
$errors[] = $errorMessage;
|
||||
\Log::error($errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'cancelled' => $cancelledCount,
|
||||
'failed' => $failedCount,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
private function cancelSingleSubscription(Subscription $subscription): void
|
||||
{
|
||||
if (! $this->stripe) {
|
||||
throw new \Exception('Stripe client not initialized');
|
||||
}
|
||||
|
||||
$subscriptionId = $subscription->stripe_subscription_id;
|
||||
|
||||
// Cancel the subscription immediately (not at period end)
|
||||
$this->stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local database
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'User account deleted',
|
||||
'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
// Call the team's subscription ended method to handle cleanup
|
||||
if ($subscription->team) {
|
||||
$subscription->team->subscriptionEnded();
|
||||
}
|
||||
|
||||
\Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a single subscription by ID (helper method for external use)
|
||||
*/
|
||||
public static function cancelById(string $subscriptionId): bool
|
||||
{
|
||||
try {
|
||||
if (! isCloud()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local record if exists
|
||||
$subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
|
||||
if ($subscription) {
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
if ($subscription->team) {
|
||||
$subscription->team->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/Actions/User/DeleteUserResources.php
Normal file
125
app/Actions/User/DeleteUserResources.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DeleteUserResources
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getResourcesPreview(): array
|
||||
{
|
||||
$applications = collect();
|
||||
$databases = collect();
|
||||
$services = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Get all servers for this team
|
||||
$servers = $team->servers;
|
||||
|
||||
foreach ($servers as $server) {
|
||||
// Get applications
|
||||
$serverApplications = $server->applications;
|
||||
$applications = $applications->merge($serverApplications);
|
||||
|
||||
// Get databases
|
||||
$serverDatabases = $this->getAllDatabasesForServer($server);
|
||||
$databases = $databases->merge($serverDatabases);
|
||||
|
||||
// Get services
|
||||
$serverServices = $server->services;
|
||||
$services = $services->merge($serverServices);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'applications' => $applications->unique('id'),
|
||||
'databases' => $databases->unique('id'),
|
||||
'services' => $services->unique('id'),
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'applications' => 0,
|
||||
'databases' => 0,
|
||||
'services' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deletedCounts = [
|
||||
'applications' => 0,
|
||||
'databases' => 0,
|
||||
'services' => 0,
|
||||
];
|
||||
|
||||
$resources = $this->getResourcesPreview();
|
||||
|
||||
// Delete applications
|
||||
foreach ($resources['applications'] as $application) {
|
||||
try {
|
||||
$application->forceDelete();
|
||||
$deletedCounts['applications']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Delete databases
|
||||
foreach ($resources['databases'] as $database) {
|
||||
try {
|
||||
$database->forceDelete();
|
||||
$deletedCounts['databases']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Delete services
|
||||
foreach ($resources['services'] as $service) {
|
||||
try {
|
||||
$service->forceDelete();
|
||||
$deletedCounts['services']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return $deletedCounts;
|
||||
}
|
||||
|
||||
private function getAllDatabasesForServer($server): Collection
|
||||
{
|
||||
$databases = collect();
|
||||
|
||||
// Get all standalone database types
|
||||
$databases = $databases->merge($server->postgresqls);
|
||||
$databases = $databases->merge($server->mysqls);
|
||||
$databases = $databases->merge($server->mariadbs);
|
||||
$databases = $databases->merge($server->mongodbs);
|
||||
$databases = $databases->merge($server->redis);
|
||||
$databases = $databases->merge($server->keydbs);
|
||||
$databases = $databases->merge($server->dragonflies);
|
||||
$databases = $databases->merge($server->clickhouses);
|
||||
|
||||
return $databases;
|
||||
}
|
||||
}
|
||||
77
app/Actions/User/DeleteUserServers.php
Normal file
77
app/Actions/User/DeleteUserServers.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DeleteUserServers
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getServersPreview(): Collection
|
||||
{
|
||||
$servers = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only include servers from teams where user is owner or admin
|
||||
$userRole = $team->pivot->role;
|
||||
if ($userRole === 'owner' || $userRole === 'admin') {
|
||||
$teamServers = $team->servers;
|
||||
$servers = $servers->merge($teamServers);
|
||||
}
|
||||
}
|
||||
|
||||
// Return unique servers (in case same server is in multiple teams)
|
||||
return $servers->unique('id');
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'servers' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
$servers = $this->getServersPreview();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
// Skip the default server (ID 0) which is the Coolify host
|
||||
if ($server->id === 0) {
|
||||
\Log::info('Skipping deletion of Coolify host server (ID: 0)');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// The Server model's forceDeleting event will handle cleanup of:
|
||||
// - destinations
|
||||
// - settings
|
||||
$server->forceDelete();
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'servers' => $deletedCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
app/Actions/User/DeleteUserTeams.php
Normal file
202
app/Actions/User/DeleteUserTeams.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
class DeleteUserTeams
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getTeamsPreview(): array
|
||||
{
|
||||
$teamsToDelete = collect();
|
||||
$teamsToTransfer = collect();
|
||||
$teamsToLeave = collect();
|
||||
$edgeCases = collect();
|
||||
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Skip root team (ID 0)
|
||||
if ($team->id === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userRole = $team->pivot->role;
|
||||
$memberCount = $team->members->count();
|
||||
|
||||
if ($memberCount === 1) {
|
||||
// User is alone in the team - delete it
|
||||
$teamsToDelete->push($team);
|
||||
} elseif ($userRole === 'owner') {
|
||||
// Check if there are other owners
|
||||
$otherOwners = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'owner';
|
||||
});
|
||||
|
||||
if ($otherOwners->isNotEmpty()) {
|
||||
// There are other owners, but check if this user is paying for the subscription
|
||||
if ($this->isUserPayingForTeamSubscription($team)) {
|
||||
// User is paying for the subscription - this is an edge case
|
||||
$edgeCases->push([
|
||||
'team' => $team,
|
||||
'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
|
||||
]);
|
||||
} else {
|
||||
// There are other owners and user is not paying, just remove this user
|
||||
$teamsToLeave->push($team);
|
||||
}
|
||||
} else {
|
||||
// User is the only owner, check for replacement
|
||||
$newOwner = $this->findNewOwner($team);
|
||||
if ($newOwner) {
|
||||
$teamsToTransfer->push([
|
||||
'team' => $team,
|
||||
'new_owner' => $newOwner,
|
||||
]);
|
||||
} else {
|
||||
// No suitable replacement found - this is an edge case
|
||||
$edgeCases->push([
|
||||
'team' => $team,
|
||||
'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is just a member - remove them from the team
|
||||
$teamsToLeave->push($team);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'to_delete' => $teamsToDelete,
|
||||
'to_transfer' => $teamsToTransfer,
|
||||
'to_leave' => $teamsToLeave,
|
||||
'edge_cases' => $edgeCases,
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'deleted' => 0,
|
||||
'transferred' => 0,
|
||||
'left' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$counts = [
|
||||
'deleted' => 0,
|
||||
'transferred' => 0,
|
||||
'left' => 0,
|
||||
];
|
||||
|
||||
$preview = $this->getTeamsPreview();
|
||||
|
||||
// Check for edge cases - should not happen here as we check earlier, but be safe
|
||||
if ($preview['edge_cases']->isNotEmpty()) {
|
||||
throw new \Exception('Edge cases detected during execution. This should not happen.');
|
||||
}
|
||||
|
||||
// Delete teams where user is alone
|
||||
foreach ($preview['to_delete'] as $team) {
|
||||
try {
|
||||
// The Team model's deleting event will handle cleanup of:
|
||||
// - private keys
|
||||
// - sources
|
||||
// - tags
|
||||
// - environment variables
|
||||
// - s3 storages
|
||||
// - notification settings
|
||||
$team->delete();
|
||||
$counts['deleted']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer ownership for teams where user is owner but not alone
|
||||
foreach ($preview['to_transfer'] as $item) {
|
||||
try {
|
||||
$team = $item['team'];
|
||||
$newOwner = $item['new_owner'];
|
||||
|
||||
// Update the new owner's role to owner
|
||||
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
|
||||
|
||||
// Remove the current user from the team
|
||||
$team->members()->detach($this->user->id);
|
||||
|
||||
$counts['transferred']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from teams where they're just a member
|
||||
foreach ($preview['to_leave'] as $team) {
|
||||
try {
|
||||
$team->members()->detach($this->user->id);
|
||||
$counts['left']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function findNewOwner(Team $team): ?User
|
||||
{
|
||||
// Only look for admins as potential new owners
|
||||
// We don't promote regular members automatically
|
||||
$otherAdmin = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'admin';
|
||||
})
|
||||
->first();
|
||||
|
||||
return $otherAdmin;
|
||||
}
|
||||
|
||||
private function isUserPayingForTeamSubscription(Team $team): bool
|
||||
{
|
||||
if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In Stripe, we need to check if the customer email matches the user's email
|
||||
// This would require a Stripe API call to get customer details
|
||||
// For now, we'll check if the subscription was created by this user
|
||||
|
||||
// Alternative approach: Check if user is the one who initiated the subscription
|
||||
// We could store this information when the subscription is created
|
||||
// For safety, we'll assume if there's an active subscription and multiple owners,
|
||||
// we should treat it as an edge case that needs manual review
|
||||
|
||||
if ($team->subscription->stripe_subscription_id &&
|
||||
$team->subscription->stripe_invoice_paid) {
|
||||
// Active subscription exists - we should be cautious
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -64,13 +64,5 @@ public function handle()
|
|||
if ($this->option('yes')) {
|
||||
$scheduled_task_executions->delete();
|
||||
}
|
||||
|
||||
// Cleanup webhooks table
|
||||
$webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days));
|
||||
$count = $webhooks->count();
|
||||
echo "Delete $count entries from webhooks.\n";
|
||||
if ($this->option('yes')) {
|
||||
$webhooks->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
722
app/Console/Commands/CloudDeleteUser.php
Normal file
722
app/Console/Commands/CloudDeleteUser.php
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Stripe\CancelSubscription;
|
||||
use App\Actions\User\DeleteUserResources;
|
||||
use App\Actions\User\DeleteUserServers;
|
||||
use App\Actions\User\DeleteUserTeams;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CloudDeleteUser extends Command
|
||||
{
|
||||
protected $signature = 'cloud:delete-user {email}
|
||||
{--dry-run : Preview what will be deleted without actually deleting}
|
||||
{--skip-stripe : Skip Stripe subscription cancellation}
|
||||
{--skip-resources : Skip resource deletion}';
|
||||
|
||||
protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation';
|
||||
|
||||
private bool $isDryRun = false;
|
||||
|
||||
private bool $skipStripe = false;
|
||||
|
||||
private bool $skipResources = false;
|
||||
|
||||
private User $user;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command is only available on cloud instances.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$email = $this->argument('email');
|
||||
$this->isDryRun = $this->option('dry-run');
|
||||
$this->skipStripe = $this->option('skip-stripe');
|
||||
$this->skipResources = $this->option('skip-resources');
|
||||
|
||||
if ($this->isDryRun) {
|
||||
$this->info('🔍 DRY RUN MODE - No data will be deleted');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->user = User::whereEmail($email)->firstOrFail();
|
||||
} catch (\Exception $e) {
|
||||
$this->error("User with email '{$email}' not found.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logAction("Starting user deletion process for: {$email}");
|
||||
|
||||
// Phase 1: Show User Overview (outside transaction)
|
||||
if (! $this->showUserOverview()) {
|
||||
$this->info('User deletion cancelled.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If not dry run, wrap everything in a transaction
|
||||
if (! $this->isDryRun) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at team handling phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at final phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
DB::commit();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ User deletion completed successfully!');
|
||||
$this->logAction("User deletion completed for: {$email}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('An error occurred during user deletion: '.$e->getMessage());
|
||||
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Dry run mode - just run through the phases without transaction
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
$this->info('User deletion would be cancelled at resource deletion phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
$this->info('User deletion would be cancelled at server deletion phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
$this->info('User deletion would be cancelled at team handling phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
$this->info('User deletion would be cancelled at final phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function showUserOverview(): bool
|
||||
{
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 1: USER OVERVIEW');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$teams = $this->user->teams;
|
||||
$ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
|
||||
$memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
|
||||
|
||||
// Collect all servers from all teams
|
||||
$allServers = collect();
|
||||
$allApplications = collect();
|
||||
$allDatabases = collect();
|
||||
$allServices = collect();
|
||||
$activeSubscriptions = collect();
|
||||
|
||||
foreach ($teams as $team) {
|
||||
$servers = $team->servers;
|
||||
$allServers = $allServers->merge($servers);
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$resources = $server->definedResources();
|
||||
foreach ($resources as $resource) {
|
||||
if ($resource instanceof \App\Models\Application) {
|
||||
$allApplications->push($resource);
|
||||
} elseif ($resource instanceof \App\Models\Service) {
|
||||
$allServices->push($resource);
|
||||
} else {
|
||||
$allDatabases->push($resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($team->subscription && $team->subscription->stripe_subscription_id) {
|
||||
$activeSubscriptions->push($team->subscription);
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User', $this->user->email],
|
||||
['User ID', $this->user->id],
|
||||
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
|
||||
['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
|
||||
['Teams (Total)', $teams->count()],
|
||||
['Teams (Owner)', $ownedTeams->count()],
|
||||
['Teams (Member)', $memberTeams->count()],
|
||||
['Servers', $allServers->unique('id')->count()],
|
||||
['Applications', $allApplications->count()],
|
||||
['Databases', $allDatabases->count()],
|
||||
['Services', $allServices->count()],
|
||||
['Active Stripe Subscriptions', $activeSubscriptions->count()],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteResources(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 2: DELETE RESOURCES');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new DeleteUserResources($this->user, $this->isDryRun);
|
||||
$resources = $action->getResourcesPreview();
|
||||
|
||||
if ($resources['applications']->isEmpty() &&
|
||||
$resources['databases']->isEmpty() &&
|
||||
$resources['services']->isEmpty()) {
|
||||
$this->info('No resources to delete.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->info('Resources to be deleted:');
|
||||
$this->newLine();
|
||||
|
||||
if ($resources['applications']->isNotEmpty()) {
|
||||
$this->warn("Applications to be deleted ({$resources['applications']->count()}):");
|
||||
$this->table(
|
||||
['Name', 'UUID', 'Server', 'Status'],
|
||||
$resources['applications']->map(function ($app) {
|
||||
return [
|
||||
$app->name,
|
||||
$app->uuid,
|
||||
$app->destination->server->name,
|
||||
$app->status ?? 'unknown',
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($resources['databases']->isNotEmpty()) {
|
||||
$this->warn("Databases to be deleted ({$resources['databases']->count()}):");
|
||||
$this->table(
|
||||
['Name', 'Type', 'UUID', 'Server'],
|
||||
$resources['databases']->map(function ($db) {
|
||||
return [
|
||||
$db->name,
|
||||
class_basename($db),
|
||||
$db->uuid,
|
||||
$db->destination->server->name,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($resources['services']->isNotEmpty()) {
|
||||
$this->warn("Services to be deleted ({$resources['services']->count()}):");
|
||||
$this->table(
|
||||
['Name', 'UUID', 'Server'],
|
||||
$resources['services']->map(function ($service) {
|
||||
return [
|
||||
$service->name,
|
||||
$service->uuid,
|
||||
$service->server->name,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
|
||||
if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Deleting resources...');
|
||||
$result = $action->execute();
|
||||
$this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
|
||||
$this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteServers(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 3: DELETE SERVERS');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new DeleteUserServers($this->user, $this->isDryRun);
|
||||
$servers = $action->getServersPreview();
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
$this->info('No servers to delete.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warn("Servers to be deleted ({$servers->count()}):");
|
||||
$this->table(
|
||||
['ID', 'Name', 'IP', 'Description', 'Resources Count'],
|
||||
$servers->map(function ($server) {
|
||||
$resourceCount = $server->definedResources()->count();
|
||||
|
||||
return [
|
||||
$server->id,
|
||||
$server->name,
|
||||
$server->ip,
|
||||
$server->description ?? '-',
|
||||
$resourceCount,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
|
||||
$this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
|
||||
if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Deleting servers...');
|
||||
$result = $action->execute();
|
||||
$this->info("Deleted {$result['servers']} servers");
|
||||
$this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function handleTeams(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 4: HANDLE TEAMS');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new DeleteUserTeams($this->user, $this->isDryRun);
|
||||
$preview = $action->getTeamsPreview();
|
||||
|
||||
// Check for edge cases first - EXIT IMMEDIATELY if found
|
||||
if ($preview['edge_cases']->isNotEmpty()) {
|
||||
$this->error('═══════════════════════════════════════');
|
||||
$this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
|
||||
$this->error('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($preview['edge_cases'] as $edgeCase) {
|
||||
$team = $edgeCase['team'];
|
||||
$reason = $edgeCase['reason'];
|
||||
$this->error("Team: {$team->name} (ID: {$team->id})");
|
||||
$this->error("Issue: {$reason}");
|
||||
|
||||
// Show team members for context
|
||||
$this->info('Current members:');
|
||||
foreach ($team->members as $member) {
|
||||
$role = $member->pivot->role;
|
||||
$this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
|
||||
}
|
||||
|
||||
// Check for active resources
|
||||
$resourceCount = 0;
|
||||
foreach ($team->servers as $server) {
|
||||
$resources = $server->definedResources();
|
||||
$resourceCount += $resources->count();
|
||||
}
|
||||
|
||||
if ($resourceCount > 0) {
|
||||
$this->warn(" ⚠️ This team has {$resourceCount} active resources!");
|
||||
}
|
||||
|
||||
// Show subscription details if relevant
|
||||
if ($team->subscription && $team->subscription->stripe_subscription_id) {
|
||||
$this->warn(' ⚠️ Active Stripe subscription details:');
|
||||
$this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
|
||||
$this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
|
||||
|
||||
// Show other owners who could potentially take over
|
||||
$otherOwners = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'owner';
|
||||
});
|
||||
|
||||
if ($otherOwners->isNotEmpty()) {
|
||||
$this->info(' Other owners who could take over billing:');
|
||||
foreach ($otherOwners as $owner) {
|
||||
$this->line(" - {$owner->name} ({$owner->email})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('Please resolve these issues manually before retrying:');
|
||||
|
||||
// Check if any edge case involves subscription payment issues
|
||||
$hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
|
||||
return str_contains($edgeCase['reason'], 'Stripe subscription');
|
||||
});
|
||||
|
||||
if ($hasSubscriptionIssue) {
|
||||
$this->info('For teams with subscription payment issues:');
|
||||
$this->info('1. Cancel the subscription through Stripe dashboard, OR');
|
||||
$this->info('2. Transfer the subscription to another owner\'s payment method, OR');
|
||||
$this->info('3. Have the other owner create a new subscription after cancelling this one');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
|
||||
return str_contains($edgeCase['reason'], 'No suitable owner replacement');
|
||||
});
|
||||
|
||||
if ($hasNoOwnerReplacement) {
|
||||
$this->info('For teams with no suitable owner replacement:');
|
||||
$this->info('1. Assign an admin role to a trusted member, OR');
|
||||
$this->info('2. Transfer team resources to another team, OR');
|
||||
$this->info('3. Delete the team manually if no longer needed');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('USER DELETION ABORTED DUE TO EDGE CASES');
|
||||
$this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
|
||||
|
||||
// Exit immediately - don't proceed with deletion
|
||||
if (! $this->isDryRun) {
|
||||
DB::rollBack();
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($preview['to_delete']->isEmpty() &&
|
||||
$preview['to_transfer']->isEmpty() &&
|
||||
$preview['to_leave']->isEmpty()) {
|
||||
$this->info('No team changes needed.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($preview['to_delete']->isNotEmpty()) {
|
||||
$this->warn('Teams to be DELETED (user is the only member):');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Resources', 'Subscription'],
|
||||
$preview['to_delete']->map(function ($team) {
|
||||
$resourceCount = 0;
|
||||
foreach ($team->servers as $server) {
|
||||
$resourceCount += $server->definedResources()->count();
|
||||
}
|
||||
$hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
|
||||
? '⚠️ YES - '.$team->subscription->stripe_subscription_id
|
||||
: 'No';
|
||||
|
||||
return [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$resourceCount,
|
||||
$hasSubscription,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($preview['to_transfer']->isNotEmpty()) {
|
||||
$this->warn('Teams where ownership will be TRANSFERRED:');
|
||||
$this->table(
|
||||
['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
|
||||
$preview['to_transfer']->map(function ($item) {
|
||||
return [
|
||||
$item['team']->id,
|
||||
$item['team']->name,
|
||||
$item['new_owner']->name,
|
||||
$item['new_owner']->email,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($preview['to_leave']->isNotEmpty()) {
|
||||
$this->warn('Teams where user will be REMOVED (other owners/admins exist):');
|
||||
$userId = $this->user->id;
|
||||
$this->table(
|
||||
['ID', 'Name', 'User Role', 'Other Members'],
|
||||
$preview['to_leave']->map(function ($team) use ($userId) {
|
||||
$userRole = $team->members->where('id', $userId)->first()->pivot->role;
|
||||
$otherMembers = $team->members->count() - 1;
|
||||
|
||||
return [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$userRole,
|
||||
$otherMembers,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('⚠️ WARNING: Team changes affect access control and ownership!');
|
||||
if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Processing team changes...');
|
||||
$result = $action->execute();
|
||||
$this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
|
||||
$this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function cancelStripeSubscriptions(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new CancelSubscription($this->user, $this->isDryRun);
|
||||
$subscriptions = $action->getSubscriptionsPreview();
|
||||
|
||||
if ($subscriptions->isEmpty()) {
|
||||
$this->info('No Stripe subscriptions to cancel.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->info('Stripe subscriptions to cancel:');
|
||||
$this->newLine();
|
||||
|
||||
$totalMonthlyValue = 0;
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$team = $subscription->team;
|
||||
$planId = $subscription->stripe_plan_id;
|
||||
|
||||
// Try to get the price from config
|
||||
$monthlyValue = $this->getSubscriptionMonthlyValue($planId);
|
||||
$totalMonthlyValue += $monthlyValue;
|
||||
|
||||
$this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
|
||||
if ($monthlyValue > 0) {
|
||||
$this->line(" Monthly value: \${$monthlyValue}");
|
||||
}
|
||||
if ($subscription->stripe_cancel_at_period_end) {
|
||||
$this->line(' ⚠️ Already set to cancel at period end');
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalMonthlyValue > 0) {
|
||||
$this->newLine();
|
||||
$this->warn("Total monthly value: \${$totalMonthlyValue}");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
|
||||
if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Cancelling subscriptions...');
|
||||
$result = $action->execute();
|
||||
$this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
|
||||
if ($result['failed'] > 0 && ! empty($result['errors'])) {
|
||||
$this->error('Failed subscriptions:');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
$this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteUserProfile(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 6: DELETE USER PROFILE');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
|
||||
$this->newLine();
|
||||
|
||||
$this->info('User profile to be deleted:');
|
||||
$this->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['Email', $this->user->email],
|
||||
['Name', $this->user->name],
|
||||
['User ID', $this->user->id],
|
||||
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
|
||||
['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
|
||||
['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
|
||||
$confirmation = $this->ask('Confirmation');
|
||||
|
||||
if ($confirmation !== "DELETE {$this->user->email}") {
|
||||
$this->error('Confirmation text does not match. Deletion cancelled.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Deleting user profile...');
|
||||
|
||||
try {
|
||||
$this->user->delete();
|
||||
$this->info('User profile deleted successfully.');
|
||||
$this->logAction("User profile deleted: {$this->user->email}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to delete user profile: '.$e->getMessage());
|
||||
$this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getSubscriptionMonthlyValue(string $planId): int
|
||||
{
|
||||
// Map plan IDs to monthly values based on config
|
||||
$subscriptionConfigs = config('subscription');
|
||||
|
||||
foreach ($subscriptionConfigs as $key => $value) {
|
||||
if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
|
||||
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic
|
||||
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
|
||||
|
||||
// Map to known prices (you may need to adjust these based on your actual pricing)
|
||||
return match ($planType) {
|
||||
'basic' => 29,
|
||||
'pro' => 49,
|
||||
'ultimate' => 99,
|
||||
default => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function logAction(string $message): void
|
||||
{
|
||||
$logMessage = "[CloudDeleteUser] {$message}";
|
||||
|
||||
if ($this->isDryRun) {
|
||||
$logMessage = "[DRY RUN] {$logMessage}";
|
||||
}
|
||||
|
||||
Log::channel('single')->info($logMessage);
|
||||
|
||||
// Also log to a dedicated user deletion log file
|
||||
$logFile = storage_path('logs/user-deletions.log');
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
|
@ -44,5 +45,6 @@ public function init()
|
|||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
}
|
||||
CheckHelperImageJob::dispatch();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class SshMultiplexingHelper
|
||||
|
|
@ -30,6 +32,7 @@ public static function ensureMultiplexedConnection(Server $server): bool
|
|||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
|
||||
// Check if connection exists
|
||||
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
|
|
@ -41,6 +44,24 @@ public static function ensureMultiplexedConnection(Server $server): bool
|
|||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
// Connection exists, ensure we have metadata for age tracking
|
||||
if (self::getConnectionAge($server) === null) {
|
||||
// Existing connection but no metadata, store current time as fallback
|
||||
self::storeConnectionMetadata($server);
|
||||
}
|
||||
|
||||
// Connection exists, check if it needs refresh due to age
|
||||
if (self::isConnectionExpired($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
// Perform health check if enabled
|
||||
if (config('constants.ssh.mux_health_check_enabled')) {
|
||||
if (! self::isConnectionHealthy($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +86,9 @@ public static function establishNewMultiplexedConnection(Server $server): bool
|
|||
return false;
|
||||
}
|
||||
|
||||
// Store connection metadata for tracking
|
||||
self::storeConnectionMetadata($server);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +103,9 @@ public static function removeMuxFile(Server $server)
|
|||
}
|
||||
$closeCommand .= "{$server->user}@{$server->ip}";
|
||||
Process::run($closeCommand);
|
||||
|
||||
// Clear connection metadata from cache
|
||||
self::clearConnectionMetadata($server);
|
||||
}
|
||||
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest)
|
||||
|
|
@ -94,8 +121,18 @@ public static function generateScpCommand(Server $server, string $source, string
|
|||
if ($server->isIpv6()) {
|
||||
$scp_command .= '-6 ';
|
||||
}
|
||||
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) {
|
||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
// Continue without multiplexing
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
|
|
@ -130,8 +167,16 @@ public static function generateSshCommand(Server $server, string $command)
|
|||
|
||||
$ssh_command = "timeout $timeout ssh ";
|
||||
|
||||
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) {
|
||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
$multiplexingSuccessful = false;
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
||||
if ($multiplexingSuccessful) {
|
||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Continue without multiplexing
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
|
|
@ -186,4 +231,81 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
|
|||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the multiplexed connection is healthy by running a test command
|
||||
*/
|
||||
public static function isConnectionHealthy(Server $server): bool
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
||||
|
||||
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'";
|
||||
|
||||
$process = Process::run($healthCommand);
|
||||
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
|
||||
return $isHealthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connection has exceeded its maximum age
|
||||
*/
|
||||
public static function isConnectionExpired(Server $server): bool
|
||||
{
|
||||
$connectionAge = self::getConnectionAge($server);
|
||||
$maxAge = config('constants.ssh.mux_max_age');
|
||||
|
||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the age of the current connection in seconds
|
||||
*/
|
||||
public static function getConnectionAge(Server $server): ?int
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
$connectionTime = Cache::get($cacheKey);
|
||||
|
||||
if ($connectionTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return time() - $connectionTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a multiplexed connection by closing and re-establishing it
|
||||
*/
|
||||
public static function refreshMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
// Close existing connection
|
||||
self::removeMuxFile($server);
|
||||
|
||||
// Establish new connection
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store connection metadata when a new connection is established
|
||||
*/
|
||||
private static function storeConnectionMetadata(Server $server): void
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear connection metadata from cache
|
||||
*/
|
||||
private static function clearConnectionMetadata(Server $server): void
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2429,7 +2429,6 @@ public function envs(Request $request)
|
|||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
|
|
@ -2470,7 +2469,7 @@ public function envs(Request $request)
|
|||
)]
|
||||
public function update_env_by_uuid(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -2495,7 +2494,6 @@ public function update_env_by_uuid(Request $request)
|
|||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
|
@ -2516,16 +2514,12 @@ public function update_env_by_uuid(Request $request)
|
|||
], 422);
|
||||
}
|
||||
$is_preview = $request->is_preview ?? false;
|
||||
$is_build_time = $request->is_build_time ?? false;
|
||||
$is_literal = $request->is_literal ?? false;
|
||||
$key = str($request->key)->trim()->replace(' ', '_')->value;
|
||||
if ($is_preview) {
|
||||
$env = $application->environment_variables_preview->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $request->value;
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
|
|
@ -2538,6 +2532,9 @@ public function update_env_by_uuid(Request $request)
|
|||
if ($env->is_shown_once != $request->is_shown_once) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) {
|
||||
$env->is_buildtime_only = $request->is_buildtime_only;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
|
|
@ -2550,9 +2547,6 @@ public function update_env_by_uuid(Request $request)
|
|||
$env = $application->environment_variables->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $request->value;
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
|
|
@ -2565,6 +2559,9 @@ public function update_env_by_uuid(Request $request)
|
|||
if ($env->is_shown_once != $request->is_shown_once) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) {
|
||||
$env->is_buildtime_only = $request->is_buildtime_only;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
|
|
@ -2619,7 +2616,6 @@ public function update_env_by_uuid(Request $request)
|
|||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
|
|
@ -2690,7 +2686,7 @@ public function create_bulk_envs(Request $request)
|
|||
], 400);
|
||||
}
|
||||
$bulk_data = collect($bulk_data)->map(function ($item) {
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']);
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
|
||||
});
|
||||
$returnedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
|
|
@ -2698,7 +2694,6 @@ public function create_bulk_envs(Request $request)
|
|||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
|
@ -2710,7 +2705,6 @@ public function create_bulk_envs(Request $request)
|
|||
], 422);
|
||||
}
|
||||
$is_preview = $item->get('is_preview') ?? false;
|
||||
$is_build_time = $item->get('is_build_time') ?? false;
|
||||
$is_literal = $item->get('is_literal') ?? false;
|
||||
$is_multi_line = $item->get('is_multiline') ?? false;
|
||||
$is_shown_once = $item->get('is_shown_once') ?? false;
|
||||
|
|
@ -2719,9 +2713,7 @@ public function create_bulk_envs(Request $request)
|
|||
$env = $application->environment_variables_preview->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $item->get('value');
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
|
|
@ -2731,16 +2723,19 @@ public function create_bulk_envs(Request $request)
|
|||
if ($env->is_shown_once != $item->get('is_shown_once')) {
|
||||
$env->is_shown_once = $item->get('is_shown_once');
|
||||
}
|
||||
if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) {
|
||||
$env->is_buildtime_only = $item->get('is_buildtime_only');
|
||||
}
|
||||
$env->save();
|
||||
} else {
|
||||
$env = $application->environment_variables()->create([
|
||||
'key' => $item->get('key'),
|
||||
'value' => $item->get('value'),
|
||||
'is_preview' => $is_preview,
|
||||
'is_build_time' => $is_build_time,
|
||||
'is_literal' => $is_literal,
|
||||
'is_multiline' => $is_multi_line,
|
||||
'is_shown_once' => $is_shown_once,
|
||||
'is_buildtime_only' => $item->get('is_buildtime_only', false),
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
@ -2749,9 +2744,6 @@ public function create_bulk_envs(Request $request)
|
|||
$env = $application->environment_variables->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $item->get('value');
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
|
|
@ -2761,16 +2753,19 @@ public function create_bulk_envs(Request $request)
|
|||
if ($env->is_shown_once != $item->get('is_shown_once')) {
|
||||
$env->is_shown_once = $item->get('is_shown_once');
|
||||
}
|
||||
if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) {
|
||||
$env->is_buildtime_only = $item->get('is_buildtime_only');
|
||||
}
|
||||
$env->save();
|
||||
} else {
|
||||
$env = $application->environment_variables()->create([
|
||||
'key' => $item->get('key'),
|
||||
'value' => $item->get('value'),
|
||||
'is_preview' => $is_preview,
|
||||
'is_build_time' => $is_build_time,
|
||||
'is_literal' => $is_literal,
|
||||
'is_multiline' => $is_multi_line,
|
||||
'is_shown_once' => $is_shown_once,
|
||||
'is_buildtime_only' => $item->get('is_buildtime_only', false),
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
@ -2814,7 +2809,6 @@ public function create_bulk_envs(Request $request)
|
|||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
|
|
@ -2854,7 +2848,7 @@ public function create_bulk_envs(Request $request)
|
|||
)]
|
||||
public function create_env(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -2874,7 +2868,6 @@ public function create_env(Request $request)
|
|||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
|
@ -2908,10 +2901,10 @@ public function create_env(Request $request)
|
|||
'key' => $request->key,
|
||||
'value' => $request->value,
|
||||
'is_preview' => $request->is_preview ?? false,
|
||||
'is_build_time' => $request->is_build_time ?? false,
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_buildtime_only' => $request->is_buildtime_only ?? false,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
@ -2931,10 +2924,10 @@ public function create_env(Request $request)
|
|||
'key' => $request->key,
|
||||
'value' => $request->value,
|
||||
'is_preview' => $request->is_preview ?? false,
|
||||
'is_build_time' => $request->is_build_time ?? false,
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_buildtime_only' => $request->is_buildtime_only ?? false,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -225,6 +225,14 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
|
|||
foreach ($uuids as $uuid) {
|
||||
$resource = getResourceByUuid($uuid, $teamId);
|
||||
if ($resource) {
|
||||
if ($pr !== 0) {
|
||||
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
|
||||
if (! $preview) {
|
||||
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
|
|
|
|||
|
|
@ -353,7 +353,6 @@ public function create_service(Request $request)
|
|||
'value' => $generatedValue,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
});
|
||||
|
|
@ -919,7 +918,6 @@ public function envs(Request $request)
|
|||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
|
|
@ -975,7 +973,6 @@ public function update_env_by_uuid(Request $request)
|
|||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
|
@ -1039,7 +1036,6 @@ public function update_env_by_uuid(Request $request)
|
|||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
|
|
@ -1105,7 +1101,6 @@ public function create_bulk_envs(Request $request)
|
|||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
|
@ -1161,7 +1156,6 @@ public function create_bulk_envs(Request $request)
|
|||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
|
|
@ -1216,7 +1210,6 @@ public function create_env(Request $request)
|
|||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
|
|
|||
|
|
@ -97,162 +97,168 @@ public function manual(Request $request)
|
|||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
$applicationsByServer = $applications->groupBy(function ($app) {
|
||||
return $app->destination->server_id;
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (! in_array($author_association, $trustedAssociations)) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (! in_array($author_association, $trustedAssociations)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
|
||||
]);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
}
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -358,141 +364,147 @@ public function normal(Request $request)
|
|||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
$applicationsByServer = $applications->groupBy(function ($app) {
|
||||
return $app->destination->server_id;
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'message' => 'Server is not functional.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (! in_array($author_association, $trustedAssociations)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (! in_array($author_association, $trustedAssociations)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
]);
|
||||
}
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
$container_name = data_get($container, 'Names');
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
});
|
||||
}
|
||||
|
||||
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
$container_name = data_get($container, 'Names');
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
});
|
||||
}
|
||||
|
||||
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,12 @@
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use App\Models\Webhook;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Stripe extends Controller
|
||||
{
|
||||
protected $webhook;
|
||||
|
||||
public function events(Request $request)
|
||||
{
|
||||
try {
|
||||
|
|
@ -40,19 +37,10 @@ public function events(Request $request)
|
|||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
}
|
||||
$this->webhook = Webhook::create([
|
||||
'type' => 'stripe',
|
||||
'payload' => $request->getContent(),
|
||||
]);
|
||||
StripeProcessJob::dispatch($event);
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
} catch (Exception $e) {
|
||||
$this->webhook->update([
|
||||
'status' => 'failed',
|
||||
'failure_reason' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response($e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ public function __construct(public int $application_deployment_queue_id)
|
|||
if ($this->pull_request_id === 0) {
|
||||
$this->container_name = $this->application->settings->custom_internal_name;
|
||||
} else {
|
||||
$this->container_name = "{$this->application->settings->custom_internal_name}-pr-{$this->pull_request_id}";
|
||||
$this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,7 +479,7 @@ private function deploy_docker_compose_buildpack()
|
|||
if (filled($this->env_filename)) {
|
||||
$services = collect(data_get($composeFile, 'services', []));
|
||||
$services = $services->map(function ($service, $name) {
|
||||
$service['env_file'] = [$this->env_filename];
|
||||
$service['env_file'] = ["/artifacts/{$this->env_filename}"];
|
||||
|
||||
return $service;
|
||||
});
|
||||
|
|
@ -504,8 +504,8 @@ private function deploy_docker_compose_buildpack()
|
|||
);
|
||||
} else {
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
if ($this->env_filename) {
|
||||
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
|
||||
if (filled($this->env_filename)) {
|
||||
$command .= " --env-file /artifacts/{$this->env_filename}";
|
||||
}
|
||||
if ($this->force_rebuild) {
|
||||
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
|
||||
|
|
@ -550,8 +550,8 @@ private function deploy_docker_compose_buildpack()
|
|||
$this->docker_compose_location = '/docker-compose.yaml';
|
||||
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
if ($this->env_filename) {
|
||||
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
|
||||
if (filled($this->env_filename)) {
|
||||
$command .= " --env-file /artifacts/{$this->env_filename}";
|
||||
}
|
||||
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -567,8 +567,8 @@ private function deploy_docker_compose_buildpack()
|
|||
} else {
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
if ($this->preserveRepository) {
|
||||
if ($this->env_filename) {
|
||||
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
|
||||
if (filled($this->env_filename)) {
|
||||
$command .= " --env-file /artifacts/{$this->env_filename}";
|
||||
}
|
||||
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
|
||||
$this->write_deployment_configurations();
|
||||
|
|
@ -577,8 +577,8 @@ private function deploy_docker_compose_buildpack()
|
|||
['command' => $command, 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
if ($this->env_filename) {
|
||||
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
|
||||
if (filled($this->env_filename)) {
|
||||
$command .= " --env-file /artifacts/{$this->env_filename}";
|
||||
}
|
||||
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -706,8 +706,8 @@ private function write_deployment_configurations()
|
|||
if ($this->pull_request_id === 0) {
|
||||
$composeFileName = "$mainDir/docker-compose.yaml";
|
||||
} else {
|
||||
$composeFileName = "$mainDir/docker-compose-pr-{$this->pull_request_id}.yaml";
|
||||
$this->docker_compose_location = "/docker-compose-pr-{$this->pull_request_id}.yaml";
|
||||
$composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
|
||||
$this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
|
||||
}
|
||||
$this->execute_remote_command([
|
||||
"mkdir -p $mainDir",
|
||||
|
|
@ -898,10 +898,10 @@ private function save_environment_variables()
|
|||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
|
||||
});
|
||||
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
|
||||
});
|
||||
}
|
||||
$ports = $this->application->main_port();
|
||||
|
|
@ -911,8 +911,11 @@ private function save_environment_variables()
|
|||
});
|
||||
if ($this->pull_request_id === 0) {
|
||||
$this->env_filename = '.env';
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
// Filter out buildtime-only variables from runtime environment
|
||||
$runtime_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
||||
return ! $env->is_buildtime_only;
|
||||
});
|
||||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
// Add PORT if not exists, use the first port as default
|
||||
|
|
@ -942,10 +945,25 @@ private function save_environment_variables()
|
|||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SERVICE_NAME for dockercompose services from processed compose
|
||||
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
||||
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
} else {
|
||||
$dockerCompose = Yaml::parse($this->application->docker_compose);
|
||||
}
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->env_filename = ".env-pr-$this->pull_request_id";
|
||||
foreach ($sorted_environment_variables_preview as $env) {
|
||||
$this->env_filename = '.env';
|
||||
// Filter out buildtime-only variables from runtime environment for preview
|
||||
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
|
||||
return ! $env->is_buildtime_only;
|
||||
});
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
// Add PORT if not exists, use the first port as default
|
||||
|
|
@ -975,46 +993,57 @@ private function save_environment_variables()
|
|||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SERVICE_NAME for dockercompose services
|
||||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||
foreach ($rawServices as $rawServiceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($envs->isEmpty()) {
|
||||
$this->env_filename = null;
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->original_server;
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
);
|
||||
$this->server = $this->build_server;
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
);
|
||||
if ($this->env_filename) {
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->original_server;
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
);
|
||||
$this->server = $this->build_server;
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->env_filename = null;
|
||||
} else {
|
||||
$envs_content = $envs->implode("\n");
|
||||
transfer_file_to_container($envs_content, "$this->workdir/{$this->env_filename}", $this->deployment_uuid, $this->server);
|
||||
transfer_file_to_container($envs_content, "/artifacts/{$this->env_filename}", $this->deployment_uuid, $this->server);
|
||||
|
||||
// Save the env filename with preview deployment suffix
|
||||
$env_filename = addPreviewDeploymentSuffix($this->env_filename, $this->pull_request_id);
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->original_server;
|
||||
transfer_file_to_server($envs_content, "$this->configuration_dir/{$this->env_filename}", $this->server);
|
||||
transfer_file_to_server($envs_content, "$this->configuration_dir/{$env_filename}", $this->server);
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
transfer_file_to_server($envs_content, "$this->configuration_dir/{$this->env_filename}", $this->server);
|
||||
transfer_file_to_server($envs_content, "$this->configuration_dir/{$env_filename}", $this->server);
|
||||
}
|
||||
}
|
||||
$this->environment_variables = $envs;
|
||||
|
|
@ -1028,32 +1057,17 @@ private function elixir_finetunes()
|
|||
$envType = 'environment_variables_preview';
|
||||
}
|
||||
$mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first();
|
||||
if ($mix_env) {
|
||||
if ($mix_env->is_build_time === false) {
|
||||
$this->application_deployment_queue->addLogEntry('MIX_ENV environment variable is not set as build time.', type: 'error');
|
||||
$this->application_deployment_queue->addLogEntry('Please set MIX_ENV environment variable to be build time variable if you facing any issues with the deployment.', type: 'error');
|
||||
}
|
||||
} else {
|
||||
if (! $mix_env) {
|
||||
$this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error');
|
||||
$this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
|
||||
}
|
||||
$secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first();
|
||||
if ($secret_key_base) {
|
||||
if ($secret_key_base->is_build_time === false) {
|
||||
$this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable is not set as build time.', type: 'error');
|
||||
$this->application_deployment_queue->addLogEntry('Please set SECRET_KEY_BASE environment variable to be build time variable if you facing any issues with the deployment.', type: 'error');
|
||||
}
|
||||
} else {
|
||||
if (! $secret_key_base) {
|
||||
$this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error');
|
||||
$this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
|
||||
}
|
||||
$database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first();
|
||||
if ($database_url) {
|
||||
if ($database_url->is_build_time === false) {
|
||||
$this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable is not set as build time.', type: 'error');
|
||||
$this->application_deployment_queue->addLogEntry('Please set DATABASE_URL environment variable to be build time variable if you facing any issues with the deployment.', type: 'error');
|
||||
}
|
||||
} else {
|
||||
if (! $database_url) {
|
||||
$this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error');
|
||||
$this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
|
||||
}
|
||||
|
|
@ -1073,7 +1087,6 @@ private function laravel_finetunes()
|
|||
$nixpacks_php_fallback_path = new EnvironmentVariable;
|
||||
$nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
|
||||
$nixpacks_php_fallback_path->value = '/index.php';
|
||||
$nixpacks_php_fallback_path->is_build_time = false;
|
||||
$nixpacks_php_fallback_path->resourceable_id = $this->application->id;
|
||||
$nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
|
||||
$nixpacks_php_fallback_path->save();
|
||||
|
|
@ -1082,7 +1095,6 @@ private function laravel_finetunes()
|
|||
$nixpacks_php_root_dir = new EnvironmentVariable;
|
||||
$nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
|
||||
$nixpacks_php_root_dir->value = '/app/public';
|
||||
$nixpacks_php_root_dir->is_build_time = false;
|
||||
$nixpacks_php_root_dir->resourceable_id = $this->application->id;
|
||||
$nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
|
||||
$nixpacks_php_root_dir->save();
|
||||
|
|
@ -1600,6 +1612,12 @@ private function generate_nixpacks_env_variables()
|
|||
}
|
||||
}
|
||||
|
||||
// Add COOLIFY_* environment variables to Nixpacks build context
|
||||
$coolify_envs = $this->generate_coolify_env_variables();
|
||||
$coolify_envs->each(function ($value, $key) {
|
||||
$this->env_nixpacks_args->push("--env {$key}={$value}");
|
||||
});
|
||||
|
||||
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
|
||||
}
|
||||
|
||||
|
|
@ -1693,8 +1711,16 @@ private function generate_env_variables()
|
|||
$this->env_args = collect([]);
|
||||
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
||||
$coolify_envs = $this->generate_coolify_env_variables();
|
||||
|
||||
// Include ALL environment variables (both build-time and runtime) for all build packs
|
||||
// This deprecates the need for is_build_time flag
|
||||
if ($this->pull_request_id === 0) {
|
||||
foreach ($this->application->build_environment_variables as $env) {
|
||||
// Get all environment variables except NIXPACKS_ prefixed ones for non-nixpacks builds
|
||||
$envs = $this->application->build_pack === 'nixpacks'
|
||||
? $this->application->runtime_environment_variables
|
||||
: $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get();
|
||||
|
||||
foreach ($envs as $env) {
|
||||
if (! is_null($env->real_value)) {
|
||||
$this->env_args->put($env->key, $env->real_value);
|
||||
if (str($env->real_value)->startsWith('$')) {
|
||||
|
|
@ -1714,7 +1740,12 @@ private function generate_env_variables()
|
|||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($this->application->build_environment_variables_preview as $env) {
|
||||
// Get all preview environment variables except NIXPACKS_ prefixed ones for non-nixpacks builds
|
||||
$envs = $this->application->build_pack === 'nixpacks'
|
||||
? $this->application->runtime_environment_variables_preview
|
||||
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
|
||||
|
||||
foreach ($envs as $env) {
|
||||
if (! is_null($env->real_value)) {
|
||||
$this->env_args->put($env->key, $env->real_value);
|
||||
if (str($env->real_value)->startsWith('$')) {
|
||||
|
|
@ -1813,8 +1844,8 @@ private function generate_compose_file()
|
|||
],
|
||||
],
|
||||
];
|
||||
if (! is_null($this->env_filename)) {
|
||||
$docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename];
|
||||
if (filled($this->env_filename)) {
|
||||
$docker_compose['services'][$this->container_name]['env_file'] = ["/artifacts/{$this->env_filename}"];
|
||||
}
|
||||
$docker_compose['services'][$this->container_name]['healthcheck'] = [
|
||||
'test' => [
|
||||
|
|
@ -1984,7 +2015,7 @@ private function generate_local_persistent_volumes()
|
|||
$volume_name = $persistentStorage->name;
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$volume_name = $volume_name.'-pr-'.$this->pull_request_id;
|
||||
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
|
||||
}
|
||||
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
|
||||
}
|
||||
|
|
@ -2002,7 +2033,7 @@ private function generate_local_persistent_volumes_only_volume_names()
|
|||
$name = $persistentStorage->name;
|
||||
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$name = $name.'-pr-'.$this->pull_request_id;
|
||||
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
|
||||
}
|
||||
|
||||
$local_persistent_volumes_names[$name] = [
|
||||
|
|
@ -2105,14 +2136,20 @@ private function build_image()
|
|||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}";
|
||||
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
|
||||
} else {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}";
|
||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
|
||||
}
|
||||
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
|
|
@ -2223,14 +2260,20 @@ private function build_image()
|
|||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
||||
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
|
||||
} else {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -2299,7 +2342,7 @@ private function stop_running_container(bool $force = false)
|
|||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($this->pull_request_id === 0) {
|
||||
$containers = $containers->filter(function ($container) {
|
||||
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id;
|
||||
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
|
||||
});
|
||||
}
|
||||
$containers->each(function ($container) {
|
||||
|
|
@ -2366,20 +2409,26 @@ private function add_build_env_variables_to_dockerfile()
|
|||
'save' => 'dockerfile',
|
||||
]);
|
||||
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
|
||||
|
||||
// Include ALL environment variables as build args (deprecating is_build_time flag)
|
||||
if ($this->pull_request_id === 0) {
|
||||
foreach ($this->application->build_environment_variables as $env) {
|
||||
// Get all environment variables except NIXPACKS_ prefixed ones
|
||||
$envs = $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get();
|
||||
foreach ($envs as $env) {
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$dockerfile->splice(1, 0, "ARG {$env->key}");
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
|
||||
} else {
|
||||
$dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}");
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($this->application->build_environment_variables_preview as $env) {
|
||||
// Get all preview environment variables except NIXPACKS_ prefixed ones
|
||||
$envs = $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
|
||||
foreach ($envs as $env) {
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$dockerfile->splice(1, 0, "ARG {$env->key}");
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
|
||||
} else {
|
||||
$dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}");
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2457,8 +2506,6 @@ private function run_post_deployment_command()
|
|||
|
||||
private function next(string $status)
|
||||
{
|
||||
queue_next_deployment($this->application);
|
||||
|
||||
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
|
||||
|
|
@ -2473,6 +2520,8 @@ private function next(string $status)
|
|||
'status' => $status,
|
||||
]);
|
||||
|
||||
queue_next_deployment($this->application);
|
||||
|
||||
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
|
||||
if (! $this->only_this_server) {
|
||||
$this->deploy_to_additional_destinations();
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DEPRECATEDContainerStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 4;
|
||||
|
||||
public function backoff(): int
|
||||
{
|
||||
return isDev() ? 1 : 3;
|
||||
}
|
||||
|
||||
public function __construct(public Server $server) {}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
GetContainersStatus::run($this->server);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Server\ResourcesCheck;
|
||||
use App\Actions\Server\ServerCheck;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DEPRECATEDServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 60;
|
||||
|
||||
public function __construct(public Server $server) {}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
ServerCheck::run($this->server);
|
||||
ResourcesCheck::dispatch($this->server);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DEPRECATEDServerResourceManager implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The time when this job execution started.
|
||||
*/
|
||||
private ?Carbon $executionTime = null;
|
||||
|
||||
private InstanceSettings $settings;
|
||||
|
||||
private string $instanceTimezone;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
(new WithoutOverlapping('server-resource-manager'))
|
||||
->releaseAfter(60),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Freeze the execution time at the start of the job
|
||||
$this->executionTime = Carbon::now();
|
||||
|
||||
$this->settings = instanceSettings();
|
||||
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
|
||||
|
||||
if (validate_timezone($this->instanceTimezone) === false) {
|
||||
$this->instanceTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// Process server checks - don't let failures stop the job
|
||||
try {
|
||||
$this->processServerChecks();
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to process server checks', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function processServerChecks(): void
|
||||
{
|
||||
$servers = $this->getServers();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
$this->processServer($server);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing server', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getServers()
|
||||
{
|
||||
$allServers = Server::where('ip', '!=', '1.2.3.4');
|
||||
|
||||
if (isCloud()) {
|
||||
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||
$own = Team::find(0)->servers;
|
||||
|
||||
return $servers->merge($own);
|
||||
} else {
|
||||
return $allServers->get();
|
||||
}
|
||||
}
|
||||
|
||||
private function processServer(Server $server): void
|
||||
{
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// Sentinel check
|
||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||
if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
||||
// Dispatch ServerCheckJob if due
|
||||
$checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
|
||||
if ($this->shouldRunNow($checkFrequency, $serverTimezone)) {
|
||||
ServerCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch ServerStorageCheckJob if due
|
||||
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||
}
|
||||
if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) {
|
||||
ServerStorageCheckJob::dispatch($server);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch DockerCleanupJob if due
|
||||
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
|
||||
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
|
||||
}
|
||||
if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
|
||||
DockerCleanupJob::dispatch($server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks);
|
||||
}
|
||||
|
||||
// Dispatch ServerPatchCheckJob if due (weekly)
|
||||
if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight
|
||||
ServerPatchCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||
if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) {
|
||||
dispatch(function () use ($server) {
|
||||
$server->restartContainer('coolify-sentinel');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldRunNow(string $frequency, string $timezone): bool
|
||||
{
|
||||
$cron = new CronExpression($frequency);
|
||||
|
||||
// Use the frozen execution time, not the current time
|
||||
$baseTime = $this->executionTime ?? Carbon::now();
|
||||
$executionTime = $baseTime->copy()->setTimezone($timezone);
|
||||
|
||||
return $cron->isDue($executionTime);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public ?string $backup_output = null;
|
||||
|
||||
public ?string $error_output = null;
|
||||
|
||||
public bool $s3_uploaded = false;
|
||||
|
||||
public ?string $postgres_password = null;
|
||||
|
||||
public ?string $mongo_root_username = null;
|
||||
|
|
@ -355,7 +359,6 @@ public function handle(): void
|
|||
// If local backup is disabled, delete the local file immediately after S3 upload
|
||||
if ($this->backup->disable_local_backup) {
|
||||
deleteBackupsLocally($this->backup_location, $this->server);
|
||||
$this->add_to_backup_output('Local backup file deleted after S3 upload (disable_local_backup enabled).');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -367,15 +370,34 @@ public function handle(): void
|
|||
'size' => $size,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $this->backup_output,
|
||||
'size' => $size,
|
||||
'filename' => null,
|
||||
]);
|
||||
// Check if backup actually failed or if it's just a post-backup issue
|
||||
$actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
|
||||
|
||||
if ($actualBackupFailed || $size === 0) {
|
||||
// Real backup failure
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
|
||||
'size' => $size,
|
||||
'filename' => null,
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
|
||||
} else {
|
||||
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'success',
|
||||
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
|
||||
'size' => $size,
|
||||
]);
|
||||
}
|
||||
// Send success notification since the backup itself succeeded
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||
// Log the post-backup issue
|
||||
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database));
|
||||
}
|
||||
}
|
||||
if ($this->backup_log && $this->backup_log->status === 'success') {
|
||||
|
|
@ -446,7 +468,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
|
|||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
|
@ -472,7 +494,7 @@ private function backup_standalone_postgresql(string $database): void
|
|||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
|
@ -492,7 +514,7 @@ private function backup_standalone_mysql(string $database): void
|
|||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
|
@ -512,7 +534,7 @@ private function backup_standalone_mariadb(string $database): void
|
|||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
|
@ -526,6 +548,15 @@ private function add_to_backup_output($output): void
|
|||
}
|
||||
}
|
||||
|
||||
private function add_to_error_output($output): void
|
||||
{
|
||||
if ($this->error_output) {
|
||||
$this->error_output = $this->error_output."\n".$output;
|
||||
} else {
|
||||
$this->error_output = $output;
|
||||
}
|
||||
}
|
||||
|
||||
private function calculate_size()
|
||||
{
|
||||
return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false);
|
||||
|
|
@ -571,9 +602,10 @@ private function upload_to_s3(): void
|
|||
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
instant_remote_process($commands, $this->server);
|
||||
|
||||
$this->add_to_backup_output('Uploaded to S3.');
|
||||
$this->s3_uploaded = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->s3_uploaded = false;
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
$command = "docker rm -f backup-of-{$this->backup->uuid}";
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
|
||||
public Collection $foundApplicationPreviewsIds;
|
||||
|
||||
public Collection $applicationContainerStatuses;
|
||||
|
||||
public bool $foundProxy = false;
|
||||
|
||||
public bool $foundLogDrainContainer = false;
|
||||
|
|
@ -87,6 +89,7 @@ public function __construct(public Server $server, public $data)
|
|||
$this->foundServiceApplicationIds = collect();
|
||||
$this->foundApplicationPreviewsIds = collect();
|
||||
$this->foundServiceDatabaseIds = collect();
|
||||
$this->applicationContainerStatuses = collect();
|
||||
$this->allApplicationIds = collect();
|
||||
$this->allDatabaseUuids = collect();
|
||||
$this->allTcpProxyUuids = collect();
|
||||
|
|
@ -155,7 +158,14 @@ public function handle()
|
|||
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
$this->updateApplicationStatus($applicationId, $containerStatus);
|
||||
// Store container status for aggregation
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
|
||||
|
|
@ -205,9 +215,86 @@ public function handle()
|
|||
|
||||
$this->updateAdditionalServersStatus();
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
$this->aggregateMultiContainerStatuses();
|
||||
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
private function aggregateMultiContainerStatuses()
|
||||
{
|
||||
if ($this->applicationContainerStatuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, don't update status
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aggregate status: if any container is running, app is running
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$aggregatedStatus = null;
|
||||
if ($hasRunning) {
|
||||
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
} else {
|
||||
// All containers are exited
|
||||
$aggregatedStatus = 'exited (unhealthy)';
|
||||
}
|
||||
|
||||
// Update application status with aggregated result
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||
{
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public function handle(): void
|
|||
case 'checkout.session.completed':
|
||||
$clientReferenceId = data_get($data, 'client_reference_id');
|
||||
if (is_null($clientReferenceId)) {
|
||||
send_internal_notification('Checkout session completed without client reference id.');
|
||||
// send_internal_notification('Checkout session completed without client reference id.');
|
||||
break;
|
||||
}
|
||||
$userId = Str::before($clientReferenceId, ':');
|
||||
|
|
@ -68,7 +68,7 @@ public function handle(): void
|
|||
$team = Team::find($teamId);
|
||||
$found = $team->members->where('id', $userId)->first();
|
||||
if (! $found->isAdmin()) {
|
||||
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
}
|
||||
$subscription = Subscription::where('team_id', $teamId)->first();
|
||||
|
|
@ -95,7 +95,7 @@ public function handle(): void
|
|||
$customerId = data_get($data, 'customer');
|
||||
$planId = data_get($data, 'lines.data.0.plan.id');
|
||||
if (Str::contains($excludedPlans, $planId)) {
|
||||
send_internal_notification('Subscription excluded.');
|
||||
// send_internal_notification('Subscription excluded.');
|
||||
break;
|
||||
}
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
|
|
@ -110,16 +110,38 @@ public function handle(): void
|
|||
break;
|
||||
case 'invoice.payment_failed':
|
||||
$customerId = data_get($data, 'customer');
|
||||
$invoiceId = data_get($data, 'id');
|
||||
$paymentIntentId = data_get($data, 'payment_intent');
|
||||
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found for customer: {$customerId}");
|
||||
}
|
||||
$team = data_get($subscription, 'team');
|
||||
if (! $team) {
|
||||
send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
|
||||
// Verify payment status with Stripe API before sending failure notification
|
||||
if ($paymentIntentId) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
||||
|
||||
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) {
|
||||
SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60));
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
SubscriptionInvoiceFailedJob::dispatch($team);
|
||||
// send_internal_notification('Invoice payment failed: '.$customerId);
|
||||
|
|
@ -129,11 +151,11 @@ public function handle(): void
|
|||
$customerId = data_get($data, 'customer');
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
if ($subscription->stripe_invoice_paid) {
|
||||
send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
|
||||
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -154,7 +176,7 @@ public function handle(): void
|
|||
$team = Team::find($teamId);
|
||||
$found = $team->members->where('id', $userId)->first();
|
||||
if (! $found->isAdmin()) {
|
||||
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
}
|
||||
$subscription = Subscription::where('team_id', $teamId)->first();
|
||||
|
|
@ -177,7 +199,7 @@ public function handle(): void
|
|||
$subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id');
|
||||
$planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id');
|
||||
if (Str::contains($excludedPlans, $planId)) {
|
||||
send_internal_notification('Subscription excluded.');
|
||||
// send_internal_notification('Subscription excluded.');
|
||||
break;
|
||||
}
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
|
|
@ -194,7 +216,7 @@ public function handle(): void
|
|||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
} else {
|
||||
send_internal_notification('No subscription and team id found');
|
||||
// send_internal_notification('No subscription and team id found');
|
||||
throw new \RuntimeException('No subscription and team id found');
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +252,7 @@ public function handle(): void
|
|||
$subscription->update([
|
||||
'stripe_past_due' => true,
|
||||
]);
|
||||
send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
// send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
}
|
||||
}
|
||||
if ($status === 'unpaid') {
|
||||
|
|
@ -238,13 +260,13 @@ public function handle(): void
|
|||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
// send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
}
|
||||
$team = data_get($subscription, 'team');
|
||||
if ($team) {
|
||||
$team->subscriptionEnded();
|
||||
} else {
|
||||
send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
}
|
||||
|
|
@ -273,11 +295,11 @@ public function handle(): void
|
|||
if ($team) {
|
||||
$team->subscriptionEnded();
|
||||
} else {
|
||||
send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
} else {
|
||||
send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,47 @@ public function __construct(protected Team $team)
|
|||
public function handle()
|
||||
{
|
||||
try {
|
||||
// Double-check subscription status before sending failure notification
|
||||
$subscription = $this->team->subscription;
|
||||
if ($subscription && $subscription->stripe_customer_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
if ($subscription->stripe_subscription_id) {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
|
||||
if (in_array($stripeSubscription->status, ['active', 'trialing'])) {
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$invoices = $stripe->invoices->all([
|
||||
'customer' => $subscription->stripe_customer_id,
|
||||
'limit' => 3,
|
||||
]);
|
||||
|
||||
foreach ($invoices->data as $invoice) {
|
||||
if ($invoice->paid && $invoice->created > (time() - 3600)) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, payment genuinely failed
|
||||
$session = getStripeCustomerPortalSession($this->team);
|
||||
$mail = new MailMessage;
|
||||
$mail->view('emails.subscription-invoice-failed', [
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ public function submit()
|
|||
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
|
||||
]);
|
||||
} else {
|
||||
send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io');
|
||||
send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
|
||||
}
|
||||
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
|
||||
$this->reset('description', 'subject');
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ private function updateServiceEnvironmentVariables()
|
|||
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
|
||||
|
||||
foreach ($domains as $serviceName => $service) {
|
||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
|
||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$domain = data_get($service, 'domain');
|
||||
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
|
||||
$this->application->environment_variables()->where('resourceable_type', Application::class)
|
||||
|
|
@ -703,7 +703,6 @@ private function updateServiceEnvironmentVariables()
|
|||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
|
|
@ -712,7 +711,6 @@ private function updateServiceEnvironmentVariables()
|
|||
'key' => "SERVICE_URL_{$serviceNameFormatted}",
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Create/update port-specific variables if port exists
|
||||
|
|
@ -721,7 +719,6 @@ private function updateServiceEnvironmentVariables()
|
|||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
|
|
@ -729,7 +726,6 @@ private function updateServiceEnvironmentVariables()
|
|||
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,18 @@ protected function setDeploymentUuid()
|
|||
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
|
||||
}
|
||||
|
||||
private function stopContainers(array $containers, $server)
|
||||
{
|
||||
$containersToStop = collect($containers)->pluck('Names')->toArray();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
}
|
||||
|
||||
public function stop(int $pull_request_id)
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Livewire\Project;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
|
|
@ -128,144 +127,10 @@ public function clone(string $type)
|
|||
$databases = $this->environment->databases();
|
||||
$services = $this->environment->services;
|
||||
foreach ($applications as $application) {
|
||||
$applicationSettings = $application->settings;
|
||||
|
||||
$uuid = (string) new Cuid2;
|
||||
$url = $application->fqdn;
|
||||
if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$url = generateUrl(server: $this->server, random: $uuid);
|
||||
}
|
||||
|
||||
$newApplication = $application->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'additional_servers_count',
|
||||
'additional_networks_count',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'fqdn' => $url,
|
||||
'status' => 'exited',
|
||||
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations)->where('id', $this->selectedDestination)->first();
|
||||
clone_application($application, $selectedDestination, [
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $this->selectedDestination,
|
||||
]);
|
||||
$newApplication->save();
|
||||
|
||||
if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
|
||||
$newApplication->custom_labels = base64_encode($customLabels);
|
||||
$newApplication->save();
|
||||
}
|
||||
|
||||
$newApplication->settings()->delete();
|
||||
if ($applicationSettings) {
|
||||
$newApplicationSettings = $applicationSettings->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $newApplication->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
}
|
||||
|
||||
$tags = $application->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newApplication->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
$scheduledTasks = $application->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $newApplication->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
$applicationPreviews = $application->previews()->get();
|
||||
foreach ($applicationPreviews as $preview) {
|
||||
$newPreview = $preview->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $newApplication->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$newPreview->save();
|
||||
}
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $application->uuid)) {
|
||||
$newName = str($volume->name)->replace($application->uuid, $newApplication->uuid);
|
||||
} else {
|
||||
$newName = $newApplication->uuid.'-'.$volume->name;
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newApplication->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopApplication::dispatch($application, false, false);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $application->destination->server;
|
||||
$targetServer = $newApplication->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
application: $application,
|
||||
server: $sourceServer,
|
||||
destination: $application->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $application->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $newApplication->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
$environmentVaribles = $application->environment_variables()->get();
|
||||
foreach ($environmentVaribles as $environmentVarible) {
|
||||
$newEnvironmentVariable = $environmentVarible->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $newApplication->id,
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
], $this->cloneVolumeData);
|
||||
}
|
||||
|
||||
foreach ($databases as $database) {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ public function submit()
|
|||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $variable,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ public function mount()
|
|||
'value' => $value,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,28 +19,28 @@ class Add extends Component
|
|||
|
||||
public ?string $value = null;
|
||||
|
||||
public bool $is_build_time = false;
|
||||
|
||||
public bool $is_multiline = false;
|
||||
|
||||
public bool $is_literal = false;
|
||||
|
||||
public bool $is_buildtime_only = false;
|
||||
|
||||
protected $listeners = ['clearAddEnv' => 'clear'];
|
||||
|
||||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'is_build_time' => 'required|boolean',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_buildtime_only' => 'required|boolean',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
'key' => 'key',
|
||||
'value' => 'value',
|
||||
'is_build_time' => 'build',
|
||||
'is_multiline' => 'multiline',
|
||||
'is_literal' => 'literal',
|
||||
'is_buildtime_only' => 'buildtime only',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -54,9 +54,9 @@ public function submit()
|
|||
$this->dispatch('saveKey', [
|
||||
'key' => $this->key,
|
||||
'value' => $this->value,
|
||||
'is_build_time' => $this->is_build_time,
|
||||
'is_multiline' => $this->is_multiline,
|
||||
'is_literal' => $this->is_literal,
|
||||
'is_buildtime_only' => $this->is_buildtime_only,
|
||||
'is_preview' => $this->is_preview,
|
||||
]);
|
||||
$this->clear();
|
||||
|
|
@ -66,8 +66,8 @@ public function clear()
|
|||
{
|
||||
$this->key = '';
|
||||
$this->value = '';
|
||||
$this->is_build_time = false;
|
||||
$this->is_multiline = false;
|
||||
$this->is_literal = false;
|
||||
$this->is_buildtime_only = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public function mount()
|
|||
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
|
||||
$this->showPreview = true;
|
||||
}
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
|
|
@ -50,33 +50,36 @@ public function instantSave()
|
|||
|
||||
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
|
||||
$this->resource->settings->save();
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
$this->dispatch('success', 'Environment variable settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function sortEnvironmentVariables()
|
||||
public function getEnvironmentVariablesProperty()
|
||||
{
|
||||
if ($this->is_env_sorting_enabled === false) {
|
||||
if ($this->resource->environment_variables) {
|
||||
$this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values();
|
||||
}
|
||||
|
||||
if ($this->resource->environment_variables_preview) {
|
||||
$this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values();
|
||||
}
|
||||
return $this->resource->environment_variables()->orderBy('order')->get();
|
||||
}
|
||||
|
||||
$this->getDevView();
|
||||
return $this->resource->environment_variables;
|
||||
}
|
||||
|
||||
public function getEnvironmentVariablesPreviewProperty()
|
||||
{
|
||||
if ($this->is_env_sorting_enabled === false) {
|
||||
return $this->resource->environment_variables_preview()->orderBy('order')->get();
|
||||
}
|
||||
|
||||
return $this->resource->environment_variables_preview;
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables);
|
||||
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
|
||||
if ($this->showPreview) {
|
||||
$this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview);
|
||||
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +100,7 @@ private function formatEnvironmentVariables($variables)
|
|||
public function switch()
|
||||
{
|
||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function submit($data = null)
|
||||
|
|
@ -111,7 +114,7 @@ public function submit($data = null)
|
|||
}
|
||||
|
||||
$this->updateOrder();
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
|
|
@ -212,9 +215,9 @@ private function createEnvironmentVariable($data)
|
|||
$environment = new EnvironmentVariable;
|
||||
$environment->key = $data['key'];
|
||||
$environment->value = $data['value'];
|
||||
$environment->is_build_time = $data['is_build_time'] ?? false;
|
||||
$environment->is_multiline = $data['is_multiline'] ?? false;
|
||||
$environment->is_literal = $data['is_literal'] ?? false;
|
||||
$environment->is_buildtime_only = $data['is_buildtime_only'] ?? false;
|
||||
$environment->is_preview = $data['is_preview'] ?? false;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
$environment->resourceable_type = $this->resource->getMorphClass();
|
||||
|
|
@ -257,7 +260,7 @@ private function updateOrCreateVariables($isPreview, $variables)
|
|||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) {
|
||||
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
|
||||
continue;
|
||||
}
|
||||
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
|
||||
|
|
@ -276,7 +279,6 @@ private function updateOrCreateVariables($isPreview, $variables)
|
|||
$environment = new EnvironmentVariable;
|
||||
$environment->key = $key;
|
||||
$environment->value = $value;
|
||||
$environment->is_build_time = false;
|
||||
$environment->is_multiline = false;
|
||||
$environment->is_preview = $isPreview;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
|
|
@ -293,7 +295,6 @@ private function updateOrCreateVariables($isPreview, $variables)
|
|||
public function refreshEnvs()
|
||||
{
|
||||
$this->resource->refresh();
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,14 +32,14 @@ class Show extends Component
|
|||
|
||||
public bool $is_shared = false;
|
||||
|
||||
public bool $is_build_time = false;
|
||||
|
||||
public bool $is_multiline = false;
|
||||
|
||||
public bool $is_literal = false;
|
||||
|
||||
public bool $is_shown_once = false;
|
||||
|
||||
public bool $is_buildtime_only = false;
|
||||
|
||||
public bool $is_required = false;
|
||||
|
||||
public bool $is_really_required = false;
|
||||
|
|
@ -55,10 +55,10 @@ class Show extends Component
|
|||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'is_build_time' => 'required|boolean',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
'is_buildtime_only' => 'required|boolean',
|
||||
'real_value' => 'nullable',
|
||||
'is_required' => 'required|boolean',
|
||||
];
|
||||
|
|
@ -101,8 +101,8 @@ public function syncData(bool $toModel = false)
|
|||
]);
|
||||
} else {
|
||||
$this->validate();
|
||||
$this->env->is_build_time = $this->is_build_time;
|
||||
$this->env->is_required = $this->is_required;
|
||||
$this->env->is_buildtime_only = $this->is_buildtime_only;
|
||||
$this->env->is_shared = $this->is_shared;
|
||||
}
|
||||
$this->env->key = $this->key;
|
||||
|
|
@ -114,10 +114,10 @@ public function syncData(bool $toModel = false)
|
|||
} else {
|
||||
$this->key = $this->env->key;
|
||||
$this->value = $this->env->value;
|
||||
$this->is_build_time = $this->env->is_build_time ?? false;
|
||||
$this->is_multiline = $this->env->is_multiline;
|
||||
$this->is_literal = $this->env->is_literal;
|
||||
$this->is_shown_once = $this->env->is_shown_once;
|
||||
$this->is_buildtime_only = $this->env->is_buildtime_only ?? false;
|
||||
$this->is_required = $this->env->is_required ?? false;
|
||||
$this->is_really_required = $this->env->is_really_required ?? false;
|
||||
$this->is_shared = $this->env->is_shared ?? false;
|
||||
|
|
@ -128,7 +128,7 @@ public function syncData(bool $toModel = false)
|
|||
public function checkEnvs()
|
||||
{
|
||||
$this->isDisabled = false;
|
||||
if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL')) {
|
||||
if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) {
|
||||
$this->isDisabled = true;
|
||||
}
|
||||
if ($this->env->is_shown_once) {
|
||||
|
|
@ -139,9 +139,6 @@ public function checkEnvs()
|
|||
public function serialize()
|
||||
{
|
||||
data_forget($this->env, 'real_value');
|
||||
if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
|
||||
data_forget($this->env, 'is_build_time');
|
||||
}
|
||||
}
|
||||
|
||||
public function lock()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
|
|
@ -61,145 +60,7 @@ public function cloneTo($destination_id)
|
|||
$server = $new_destination->server;
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$name = 'clone-of-'.str($this->resource->name)->limit(20).'-'.$uuid;
|
||||
$applicationSettings = $this->resource->settings;
|
||||
$url = $this->resource->fqdn;
|
||||
|
||||
if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$url = generateUrl(server: $server, random: $uuid);
|
||||
}
|
||||
|
||||
$new_resource = $this->resource->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'additional_servers_count',
|
||||
'additional_networks_count',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'name' => $name,
|
||||
'fqdn' => $url,
|
||||
'status' => 'exited',
|
||||
'destination_id' => $new_destination->id,
|
||||
]);
|
||||
$new_resource->save();
|
||||
|
||||
if ($new_resource->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($new_resource)))->replace('|coolify|', "\n");
|
||||
$new_resource->custom_labels = base64_encode($customLabels);
|
||||
$new_resource->save();
|
||||
}
|
||||
|
||||
$new_resource->settings()->delete();
|
||||
if ($applicationSettings) {
|
||||
$newApplicationSettings = $applicationSettings->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $new_resource->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
}
|
||||
|
||||
$tags = $this->resource->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$new_resource->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
$scheduledTasks = $this->resource->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $new_resource->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
$applicationPreviews = $this->resource->previews()->get();
|
||||
foreach ($applicationPreviews as $preview) {
|
||||
$newPreview = $preview->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $new_resource->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$newPreview->save();
|
||||
}
|
||||
|
||||
$persistentVolumes = $this->resource->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $this->resource->uuid)) {
|
||||
$newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid);
|
||||
} else {
|
||||
$newName = $new_resource->uuid.'-'.str($volume->name)->afterLast('-');
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $new_resource->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopApplication::dispatch($this->resource, false, false);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $this->resource->destination->server;
|
||||
$targetServer = $new_resource->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
application: $this->resource,
|
||||
server: $sourceServer,
|
||||
destination: $this->resource->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $this->resource->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $new_resource->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
$environmentVaribles = $this->resource->environment_variables()->get();
|
||||
foreach ($environmentVaribles as $environmentVarible) {
|
||||
$newEnvironmentVariable = $environmentVarible->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $new_resource->id,
|
||||
'resourceable_type' => $new_resource->getMorphClass(),
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
|
||||
|
||||
$route = route('project.application.configuration', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
|
|
|
|||
|
|
@ -9,4 +9,15 @@ class All extends Component
|
|||
public $resource;
|
||||
|
||||
protected $listeners = ['refreshStorages' => '$refresh'];
|
||||
|
||||
public function getFirstStorageIdProperty()
|
||||
{
|
||||
if ($this->resource->persistentStorages->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the storage with the smallest ID as the "first" one
|
||||
// This ensures stability even when storages are deleted
|
||||
return $this->resource->persistentStorages->sortBy('id')->first()->id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ class Show extends Component
|
|||
|
||||
public bool $isSentinelDebugEnabled;
|
||||
|
||||
public ?string $sentinelCustomDockerImage = null;
|
||||
|
||||
public string $serverTimezone;
|
||||
|
||||
public function getListeners()
|
||||
|
|
@ -267,7 +269,8 @@ public function restartSentinel()
|
|||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
$this->server->restartSentinel();
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
$this->server->restartSentinel($customImage);
|
||||
$this->dispatch('success', 'Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -300,7 +303,8 @@ public function updatedIsSentinelEnabled($value)
|
|||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
if ($value === true) {
|
||||
StartSentinel::run($this->server, true);
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
StartSentinel::run($this->server, true, null, $customImage);
|
||||
} else {
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
|
|
|
|||
|
|
@ -728,7 +728,14 @@ public function environment_variables()
|
|||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function runtime_environment_variables()
|
||||
|
|
@ -738,14 +745,6 @@ public function runtime_environment_variables()
|
|||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function build_environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->where('is_build_time', true)
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function nixpacks_environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
|
|
@ -757,7 +756,14 @@ public function environment_variables_preview()
|
|||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function runtime_environment_variables_preview()
|
||||
|
|
@ -767,14 +773,6 @@ public function runtime_environment_variables_preview()
|
|||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function build_environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->where('is_build_time', true)
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function nixpacks_environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
|
|
@ -936,9 +934,9 @@ public function isConfigurationChanged(bool $save = false)
|
|||
{
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal'])->sort());
|
||||
} else {
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get('value')->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal'])->sort());
|
||||
}
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
|
@ -1474,14 +1472,14 @@ public function loadComposeFile($isInit = false)
|
|||
$json = collect(json_decode($this->docker_compose_domains));
|
||||
foreach ($json as $key => $value) {
|
||||
if (str($key)->contains('-')) {
|
||||
$key = str($key)->replace('-', '_');
|
||||
$key = str($key)->replace('-', '_')->replace('.', '_');
|
||||
}
|
||||
$json->put((string) $key, $value);
|
||||
}
|
||||
$services = collect(data_get($parsedServices, 'services', []));
|
||||
foreach ($services as $name => $service) {
|
||||
if (str($name)->contains('-')) {
|
||||
$replacedName = str($name)->replace('-', '_');
|
||||
$replacedName = str($name)->replace('-', '_')->replace('.', '_');
|
||||
$services->put((string) $replacedName, $service);
|
||||
$services->forget((string) $name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
'uuid' => ['type' => 'string'],
|
||||
'resourceable_type' => ['type' => 'string'],
|
||||
'resourceable_id' => ['type' => 'integer'],
|
||||
'is_build_time' => ['type' => 'boolean'],
|
||||
'is_literal' => ['type' => 'boolean'],
|
||||
'is_multiline' => ['type' => 'boolean'],
|
||||
'is_preview' => ['type' => 'boolean'],
|
||||
'is_buildtime_only' => ['type' => 'boolean'],
|
||||
'is_shared' => ['type' => 'boolean'],
|
||||
'is_shown_once' => ['type' => 'boolean'],
|
||||
'key' => ['type' => 'string'],
|
||||
|
|
@ -35,9 +35,9 @@ class EnvironmentVariable extends BaseModel
|
|||
protected $casts = [
|
||||
'key' => 'string',
|
||||
'value' => 'encrypted',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_preview' => 'boolean',
|
||||
'is_buildtime_only' => 'boolean',
|
||||
'version' => 'string',
|
||||
'resourceable_type' => 'string',
|
||||
'resourceable_id' => 'integer',
|
||||
|
|
@ -61,8 +61,8 @@ protected static function booted()
|
|||
ModelsEnvironmentVariable::create([
|
||||
'key' => $environment_variable->key,
|
||||
'value' => $environment_variable->value,
|
||||
'is_build_time' => $environment_variable->is_build_time,
|
||||
'is_multiline' => $environment_variable->is_multiline ?? false,
|
||||
'is_literal' => $environment_variable->is_literal ?? false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $environment_variable->resourceable_id,
|
||||
'is_preview' => true,
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Kubernetes extends BaseModel {}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
|
@ -99,11 +100,18 @@ public static function validatePrivateKey($privateKey)
|
|||
|
||||
public static function createAndStore(array $data)
|
||||
{
|
||||
$privateKey = new self($data);
|
||||
$privateKey->save();
|
||||
$privateKey->storeInFileSystem();
|
||||
return DB::transaction(function () use ($data) {
|
||||
$privateKey = new self($data);
|
||||
$privateKey->save();
|
||||
|
||||
return $privateKey;
|
||||
try {
|
||||
$privateKey->storeInFileSystem();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Failed to store SSH key: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $privateKey;
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateNewKeyPair($type = 'rsa')
|
||||
|
|
@ -151,15 +159,64 @@ public static function validateAndExtractPublicKey($privateKey)
|
|||
public function storeInFileSystem()
|
||||
{
|
||||
$filename = "ssh_key@{$this->uuid}";
|
||||
Storage::disk('ssh-keys')->put($filename, $this->private_key);
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
|
||||
return "/var/www/html/storage/app/ssh/keys/{$filename}";
|
||||
// Ensure the storage directory exists and is writable
|
||||
$this->ensureStorageDirectoryExists();
|
||||
|
||||
// Attempt to store the private key
|
||||
$success = $disk->put($filename, $this->private_key);
|
||||
|
||||
if (! $success) {
|
||||
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
|
||||
}
|
||||
|
||||
// Verify the file was actually created and has content
|
||||
if (! $disk->exists($filename)) {
|
||||
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
|
||||
}
|
||||
|
||||
$storedContent = $disk->get($filename);
|
||||
if (empty($storedContent) || $storedContent !== $this->private_key) {
|
||||
$disk->delete($filename); // Clean up the bad file
|
||||
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
|
||||
}
|
||||
|
||||
return $this->getKeyLocation();
|
||||
}
|
||||
|
||||
public static function deleteFromStorage(self $privateKey)
|
||||
{
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
Storage::disk('ssh-keys')->delete($filename);
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
|
||||
if ($disk->exists($filename)) {
|
||||
$disk->delete($filename);
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureStorageDirectoryExists()
|
||||
{
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$directoryPath = '';
|
||||
|
||||
if (! $disk->exists($directoryPath)) {
|
||||
$success = $disk->makeDirectory($directoryPath);
|
||||
if (! $success) {
|
||||
throw new \Exception('Failed to create SSH keys storage directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if directory is writable by attempting a test file
|
||||
$testFilename = '.test_write_'.uniqid();
|
||||
$testSuccess = $disk->put($testFilename, 'test');
|
||||
|
||||
if (! $testSuccess) {
|
||||
throw new \Exception('SSH keys storage directory is not writable');
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
$disk->delete($testFilename);
|
||||
}
|
||||
|
||||
public function getKeyLocation()
|
||||
|
|
@ -169,10 +226,17 @@ public function getKeyLocation()
|
|||
|
||||
public function updatePrivateKey(array $data)
|
||||
{
|
||||
$this->update($data);
|
||||
$this->storeInFileSystem();
|
||||
return DB::transaction(function () use ($data) {
|
||||
$this->update($data);
|
||||
|
||||
return $this;
|
||||
try {
|
||||
$this->storeInFileSystem();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this;
|
||||
});
|
||||
}
|
||||
|
||||
public function servers()
|
||||
|
|
|
|||
|
|
@ -1252,13 +1252,13 @@ public function isIpv6(): bool
|
|||
return str($this->ip)->contains(':');
|
||||
}
|
||||
|
||||
public function restartSentinel(bool $async = true)
|
||||
public function restartSentinel(?string $customImage = null, bool $async = true)
|
||||
{
|
||||
try {
|
||||
if ($async) {
|
||||
StartSentinel::dispatch($this, true);
|
||||
StartSentinel::dispatch($this, true, null, $customImage);
|
||||
} else {
|
||||
StartSentinel::run($this, true);
|
||||
StartSentinel::run($this, true, null, $customImage);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
|
|
|
|||
|
|
@ -1113,7 +1113,6 @@ public function saveExtraFields($fields)
|
|||
$this->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'resourceable_id' => $this->id,
|
||||
'resourceable_type' => $this->getMorphClass(),
|
||||
'is_preview' => false,
|
||||
|
|
@ -1230,14 +1229,14 @@ public function scheduled_tasks(): HasMany
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
}
|
||||
|
||||
public function environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function workdir()
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -267,7 +266,14 @@ public function destination()
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function runtime_environment_variables()
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -342,6 +341,13 @@ public function isBackupSolutionAvailable()
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -342,6 +341,13 @@ public function isBackupSolutionAvailable()
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -263,7 +262,14 @@ public function destination(): MorphTo
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function runtime_environment_variables()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'mongodb-db-'.$database->uuid,
|
||||
|
|
@ -32,7 +31,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -365,6 +363,13 @@ public function isBackupSolutionAvailable()
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -346,6 +345,13 @@ public function isBackupSolutionAvailable()
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -297,7 +296,14 @@ public function scheduledBackups()
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function isBackupSolutionAvailable()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ protected static function booted()
|
|||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
|
|
@ -389,6 +388,13 @@ public function redisUsername(): Attribute
|
|||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Webhook extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'type' => 'string',
|
||||
'payload' => 'encrypted',
|
||||
];
|
||||
}
|
||||
|
|
@ -129,7 +129,6 @@ protected function getEnvironmentVariables(): array
|
|||
$variables->push([
|
||||
'key' => $env->key,
|
||||
'value' => $env->value,
|
||||
'is_build_time' => $env->is_build_time,
|
||||
'is_preview' => $env->is_preview,
|
||||
'is_multiline' => $env->is_multiline,
|
||||
]);
|
||||
|
|
@ -145,7 +144,6 @@ protected function getPreviewEnvironmentVariables(): array
|
|||
$variables->push([
|
||||
'key' => $env->key,
|
||||
'value' => $env->value,
|
||||
'is_build_time' => $env->is_build_time,
|
||||
'is_preview' => $env->is_preview,
|
||||
'is_multiline' => $env->is_multiline,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ trait EnvironmentVariableProtection
|
|||
*/
|
||||
protected function isProtectedEnvironmentVariable(string $key): bool
|
||||
{
|
||||
return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
|
||||
return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Spatie\Url\Url;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false)
|
||||
{
|
||||
|
|
@ -68,7 +71,7 @@ function queue_application_deployment(Application $application, string $deployme
|
|||
ApplicationDeploymentJob::dispatch(
|
||||
application_deployment_queue_id: $deployment->id,
|
||||
);
|
||||
} elseif (next_queuable($server_id, $application_id, $commit)) {
|
||||
} elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) {
|
||||
ApplicationDeploymentJob::dispatch(
|
||||
application_deployment_queue_id: $deployment->id,
|
||||
);
|
||||
|
|
@ -93,32 +96,31 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment)
|
|||
function queue_next_deployment(Application $application)
|
||||
{
|
||||
$server_id = $application->destination->server_id;
|
||||
$next_found = ApplicationDeploymentQueue::where('server_id', $server_id)->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at')->first();
|
||||
if ($next_found) {
|
||||
$next_found->update([
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
$queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id)
|
||||
->where('status', ApplicationDeploymentStatus::QUEUED)
|
||||
->get()
|
||||
->sortBy('created_at');
|
||||
|
||||
ApplicationDeploymentJob::dispatch(
|
||||
application_deployment_queue_id: $next_found->id,
|
||||
);
|
||||
foreach ($queued_deployments as $next_deployment) {
|
||||
// Check if this queued deployment can actually run
|
||||
if (next_queuable($next_deployment->server_id, $next_deployment->application_id, $next_deployment->commit, $next_deployment->pull_request_id)) {
|
||||
$next_deployment->update([
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
ApplicationDeploymentJob::dispatch(
|
||||
application_deployment_queue_id: $next_deployment->id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD'): bool
|
||||
function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD', int $pull_request_id = 0): bool
|
||||
{
|
||||
// Check if there's already a deployment in progress for this application and commit
|
||||
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
|
||||
->where('commit', $commit)
|
||||
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
|
||||
->first();
|
||||
|
||||
if ($existing_deployment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's any deployment in progress for this application
|
||||
// Check if there's already a deployment in progress for this application with the same pull_request_id
|
||||
// This allows normal deployments and PR deployments to run concurrently
|
||||
$in_progress = ApplicationDeploymentQueue::where('application_id', $application_id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
|
||||
->exists();
|
||||
|
||||
|
|
@ -142,13 +144,15 @@ function next_queuable(string $server_id, string $application_id, string $commit
|
|||
function next_after_cancel(?Server $server = null)
|
||||
{
|
||||
if ($server) {
|
||||
$next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at');
|
||||
$next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))
|
||||
->where('status', ApplicationDeploymentStatus::QUEUED)
|
||||
->get()
|
||||
->sortBy('created_at');
|
||||
|
||||
if ($next_found->count() > 0) {
|
||||
foreach ($next_found as $next) {
|
||||
$server = Server::find($next->server_id);
|
||||
$concurrent_builds = $server->settings->concurrent_builds;
|
||||
$inprogress_deployments = ApplicationDeploymentQueue::where('server_id', $next->server_id)->whereIn('status', [ApplicationDeploymentStatus::QUEUED])->get()->sortByDesc('created_at');
|
||||
if ($inprogress_deployments->count() < $concurrent_builds) {
|
||||
// Use next_queuable to properly check if this deployment can run
|
||||
if (next_queuable($next->server_id, $next->application_id, $next->commit, $next->pull_request_id)) {
|
||||
$next->update([
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
|
@ -157,8 +161,195 @@ function next_after_cancel(?Server $server = null)
|
|||
application_deployment_queue_id: $next->id,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
|
||||
{
|
||||
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
|
||||
$server = $destination->server;
|
||||
|
||||
// Prepare name and URL
|
||||
$name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
|
||||
$applicationSettings = $source->settings;
|
||||
$url = $overrides['fqdn'] ?? $source->fqdn;
|
||||
|
||||
if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$url = generateUrl(server: $server, random: $uuid);
|
||||
}
|
||||
|
||||
// Clone the application
|
||||
$newApplication = $source->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'additional_servers_count',
|
||||
'additional_networks_count',
|
||||
])->fill(array_merge([
|
||||
'uuid' => $uuid,
|
||||
'name' => $name,
|
||||
'fqdn' => $url,
|
||||
'status' => 'exited',
|
||||
'destination_id' => $destination->id,
|
||||
], $overrides));
|
||||
$newApplication->save();
|
||||
|
||||
// Update custom labels if needed
|
||||
if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
|
||||
$newApplication->custom_labels = base64_encode($customLabels);
|
||||
$newApplication->save();
|
||||
}
|
||||
|
||||
// Clone settings
|
||||
$newApplication->settings()->delete();
|
||||
if ($applicationSettings) {
|
||||
$newApplicationSettings = $applicationSettings->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $newApplication->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
}
|
||||
|
||||
// Clone tags
|
||||
$tags = $source->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newApplication->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
// Clone scheduled tasks
|
||||
$scheduledTasks = $source->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $newApplication->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
// Clone previews with FQDN regeneration
|
||||
$applicationPreviews = $source->previews()->get();
|
||||
foreach ($applicationPreviews as $preview) {
|
||||
$newPreview = $preview->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $newApplication->id,
|
||||
'status' => 'exited',
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
$newPreview->save();
|
||||
|
||||
// Regenerate FQDN for the cloned preview
|
||||
if ($newApplication->build_pack === 'dockercompose') {
|
||||
$newPreview->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$newPreview->generate_preview_fqdn();
|
||||
}
|
||||
}
|
||||
|
||||
// Clone persistent volumes
|
||||
$persistentVolumes = $source->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $source->uuid)) {
|
||||
$newName = str($volume->name)->replace($source->uuid, $newApplication->uuid);
|
||||
} else {
|
||||
$newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-');
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newApplication->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($cloneVolumeData) {
|
||||
try {
|
||||
StopApplication::dispatch($source, false, false);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $source->destination->server;
|
||||
$targetServer = $newApplication->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
application: $source,
|
||||
server: $sourceServer,
|
||||
destination: $source->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone file storages
|
||||
$fileStorages = $source->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $newApplication->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
// Clone production environment variables without triggering the created hook
|
||||
$environmentVariables = $source->environment_variables()->get();
|
||||
foreach ($environmentVariables as $environmentVariable) {
|
||||
\App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
|
||||
$newEnvironmentVariable = $environmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $newApplication->id,
|
||||
'resourceable_type' => $newApplication->getMorphClass(),
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
});
|
||||
}
|
||||
|
||||
// Clone preview environment variables
|
||||
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
|
||||
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
|
||||
\App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
|
||||
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $newApplication->id,
|
||||
'resourceable_type' => $newApplication->getMorphClass(),
|
||||
'is_preview' => true,
|
||||
]);
|
||||
$newPreviewEnvironmentVariable->save();
|
||||
});
|
||||
}
|
||||
|
||||
return $newApplication;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,7 +342,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
@ -355,7 +354,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
@ -373,7 +371,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
||||
$originalFqdnFor = str($fqdnFor)->replace('_', '-');
|
||||
if (str($fqdnFor)->contains('-')) {
|
||||
$fqdnFor = str($fqdnFor)->replace('-', '_');
|
||||
$fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
|
||||
}
|
||||
// Generated FQDN & URL
|
||||
$fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
|
|
@ -384,7 +382,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
|
|
@ -409,7 +406,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
||||
$originalUrlFor = str($urlFor)->replace('_', '-');
|
||||
if (str($urlFor)->contains('-')) {
|
||||
$urlFor = str($urlFor)->replace('-', '_');
|
||||
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
|
||||
}
|
||||
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
|
|
@ -418,7 +415,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
|
|
@ -446,7 +442,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
@ -454,6 +449,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
}
|
||||
}
|
||||
|
||||
// generate SERVICE_NAME variables for docker compose services
|
||||
$serviceNameEnvironments = collect([]);
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
$serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
|
||||
}
|
||||
|
||||
// Parse the rest of the services
|
||||
foreach ($services as $serviceName => $service) {
|
||||
$image = data_get_str($service, 'image');
|
||||
|
|
@ -567,7 +568,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
}
|
||||
$source = replaceLocalSource($source, $mainDirectory);
|
||||
if ($isPullRequest) {
|
||||
$source = $source."-pr-$pullRequestId";
|
||||
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
}
|
||||
LocalFileVolume::updateOrCreate(
|
||||
[
|
||||
|
|
@ -610,7 +611,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$name = "{$uuid}_{$slugWithoutUuid}";
|
||||
|
||||
if ($isPullRequest) {
|
||||
$name = "{$name}-pr-$pullRequestId";
|
||||
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
}
|
||||
if (is_string($volume)) {
|
||||
$parsed = parseDockerVolumeString($volume);
|
||||
|
|
@ -651,11 +652,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$newDependsOn = collect([]);
|
||||
$depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
|
||||
if (is_numeric($condition)) {
|
||||
$dependency = "$dependency-pr-$pullRequestId";
|
||||
$dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
|
||||
|
||||
$newDependsOn->put($condition, $dependency);
|
||||
} else {
|
||||
$condition = "$condition-pr-$pullRequestId";
|
||||
$condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
|
||||
$newDependsOn->put($condition, $dependency);
|
||||
}
|
||||
});
|
||||
|
|
@ -754,7 +755,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
|
|
@ -771,7 +771,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
} else {
|
||||
|
|
@ -807,7 +806,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
|
|
@ -822,7 +820,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
|
|
@ -858,13 +855,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($resource->build_pack !== 'dockercompose') {
|
||||
$domains = collect([]);
|
||||
}
|
||||
$changedServiceName = str($serviceName)->replace('-', '_')->value();
|
||||
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
$fqdns = data_get($domains, "$changedServiceName.domain");
|
||||
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
foreach ($domains as $forServiceName => $domain) {
|
||||
$parsedDomain = data_get($domain, 'domain');
|
||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
|
||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
||||
|
||||
if (filled($parsedDomain)) {
|
||||
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
||||
|
|
@ -872,24 +869,22 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyUrl->__toString());
|
||||
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyFqdn);
|
||||
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
|
||||
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $resource->id,
|
||||
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'),
|
||||
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
|
||||
], [
|
||||
'value' => $coolifyUrl->__toString(),
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $resource->id,
|
||||
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'),
|
||||
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
|
||||
], [
|
||||
'value' => $coolifyFqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
} else {
|
||||
|
|
@ -1082,7 +1077,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$payload['volumes'] = $volumesParsed;
|
||||
}
|
||||
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
|
||||
$payload['environment'] = $environment->merge($coolifyEnvironments);
|
||||
$payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
|
||||
}
|
||||
if ($logging) {
|
||||
$payload['logging'] = $logging;
|
||||
|
|
@ -1091,7 +1086,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$payload['depends_on'] = $depends_on;
|
||||
}
|
||||
if ($isPullRequest) {
|
||||
$serviceName = "{$serviceName}-pr-{$pullRequestId}";
|
||||
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
|
||||
}
|
||||
|
||||
$parsedServices->put($serviceName, $payload);
|
||||
|
|
@ -1337,7 +1332,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
|
|
@ -1346,7 +1340,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
@ -1358,7 +1351,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
|
|
@ -1367,7 +1359,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
@ -1397,7 +1388,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
|
|
@ -1417,7 +1407,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
|
|
@ -1429,7 +1418,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
@ -1748,7 +1736,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
|
|
@ -1765,7 +1752,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
} else {
|
||||
|
|
@ -1801,7 +1787,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
|
|
@ -1816,7 +1801,6 @@ function serviceParser(Service $resource): Collection
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ function transfer_file_to_container(string $content, string $container_path, str
|
|||
return instant_remote_process_with_timeout($commands, $server, $throwError);
|
||||
|
||||
} finally {
|
||||
ray($temp_file);
|
||||
// Always cleanup local temp file
|
||||
if (file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
|
|
|
|||
|
|
@ -114,14 +114,14 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||
$resource->save();
|
||||
}
|
||||
|
||||
$serviceName = str($resource->name)->upper()->replace('-', '_');
|
||||
$serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
|
||||
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
|
||||
|
||||
if ($resource->fqdn) {
|
||||
$resourceFqdns = str($resource->fqdn)->explode(',');
|
||||
$resourceFqdns = $resourceFqdns->first();
|
||||
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
|
||||
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$url = Url::fromString($resourceFqdns);
|
||||
$port = $url->getPort();
|
||||
$path = $url->getPath();
|
||||
|
|
@ -133,7 +133,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||
'key' => $variableName,
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($port) {
|
||||
|
|
@ -144,11 +143,10 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||
'key' => $variableName,
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
|
||||
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$fqdn = Url::fromString($resourceFqdns);
|
||||
$port = $fqdn->getPort();
|
||||
$path = $fqdn->getPath();
|
||||
|
|
@ -163,7 +161,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||
'key' => $variableName,
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($port) {
|
||||
|
|
@ -174,7 +171,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||
'key' => $variableName,
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1564,7 +1564,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -1644,7 +1643,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -1683,7 +1681,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $generatedValue,
|
||||
'is_build_time' => false,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -1722,7 +1719,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $defaultValue,
|
||||
'is_build_time' => false,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -1986,12 +1982,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$name = $name->replaceFirst('~', $dir);
|
||||
}
|
||||
if ($pull_request_id !== 0) {
|
||||
$name = $name."-pr-$pull_request_id";
|
||||
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
}
|
||||
$volume = str("$name:$mount");
|
||||
} else {
|
||||
if ($pull_request_id !== 0) {
|
||||
$name = $name."-pr-$pull_request_id";
|
||||
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
$volume = str("$name:$mount");
|
||||
if ($topLevelVolumes->has($name)) {
|
||||
$v = $topLevelVolumes->get($name);
|
||||
|
|
@ -2030,7 +2026,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$name = $volume->before(':');
|
||||
$mount = $volume->after(':');
|
||||
if ($pull_request_id !== 0) {
|
||||
$name = $name."-pr-$pull_request_id";
|
||||
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
}
|
||||
$volume = str("$name:$mount");
|
||||
}
|
||||
|
|
@ -2049,7 +2045,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$source = str($source)->replaceFirst('~', $dir);
|
||||
}
|
||||
if ($pull_request_id !== 0) {
|
||||
$source = $source."-pr-$pull_request_id";
|
||||
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
}
|
||||
if ($read_only) {
|
||||
data_set($volume, 'source', $source.':'.$target.':ro');
|
||||
|
|
@ -2058,7 +2054,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
}
|
||||
} else {
|
||||
if ($pull_request_id !== 0) {
|
||||
$source = $source."-pr-$pull_request_id";
|
||||
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
}
|
||||
if ($read_only) {
|
||||
data_set($volume, 'source', $source.':'.$target.':ro');
|
||||
|
|
@ -2110,13 +2106,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$name = $name->replaceFirst('~', $dir);
|
||||
}
|
||||
if ($pull_request_id !== 0) {
|
||||
$name = $name."-pr-$pull_request_id";
|
||||
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
}
|
||||
$volume = str("$name:$mount");
|
||||
} else {
|
||||
if ($pull_request_id !== 0) {
|
||||
$uuid = $resource->uuid;
|
||||
$name = $uuid."-$name-pr-$pull_request_id";
|
||||
$name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
$volume = str("$name:$mount");
|
||||
if ($topLevelVolumes->has($name)) {
|
||||
$v = $topLevelVolumes->get($name);
|
||||
|
|
@ -2158,7 +2154,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$name = $volume->before(':');
|
||||
$mount = $volume->after(':');
|
||||
if ($pull_request_id !== 0) {
|
||||
$name = $name."-pr-$pull_request_id";
|
||||
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
||||
}
|
||||
$volume = str("$name:$mount");
|
||||
}
|
||||
|
|
@ -2186,7 +2182,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
if ($pull_request_id === 0) {
|
||||
$source = $uuid."-$source";
|
||||
} else {
|
||||
$source = $uuid."-$source-pr-$pull_request_id";
|
||||
$source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
}
|
||||
if ($read_only) {
|
||||
data_set($volume, 'source', $source.':'.$target.':ro');
|
||||
|
|
@ -2226,7 +2222,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
|
||||
if ($pull_request_id !== 0 && count($serviceDependencies) > 0) {
|
||||
$serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) {
|
||||
return $dependency."-pr-$pull_request_id";
|
||||
return addPreviewDeploymentSuffix($dependency, $pull_request_id);
|
||||
});
|
||||
data_set($service, 'depends_on', $serviceDependencies->toArray());
|
||||
}
|
||||
|
|
@ -2413,7 +2409,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $fqdn,
|
||||
'is_build_time' => false,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -2425,7 +2420,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $generatedValue,
|
||||
'is_build_time' => false,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -2459,20 +2453,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
if ($foundEnv) {
|
||||
$defaultValue = data_get($foundEnv, 'value');
|
||||
}
|
||||
$isBuildTime = data_get($foundEnv, 'is_build_time', false);
|
||||
if ($foundEnv) {
|
||||
$foundEnv->update([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_build_time' => $isBuildTime,
|
||||
'value' => $defaultValue,
|
||||
]);
|
||||
} else {
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $defaultValue,
|
||||
'is_build_time' => $isBuildTime,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
|
|
@ -2620,7 +2611,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
});
|
||||
if ($pull_request_id !== 0) {
|
||||
$services->each(function ($service, $serviceName) use ($pull_request_id, $services) {
|
||||
$services[$serviceName."-pr-$pull_request_id"] = $service;
|
||||
$services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service;
|
||||
data_forget($services, $serviceName);
|
||||
});
|
||||
}
|
||||
|
|
@ -3000,3 +2991,18 @@ function parseDockerfileInterval(string $something)
|
|||
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string
|
||||
{
|
||||
return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id;
|
||||
}
|
||||
|
||||
function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection
|
||||
{
|
||||
$collection = collect([]);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
$collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId));
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@
|
|||
'ssh' => [
|
||||
'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)),
|
||||
'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600),
|
||||
'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true),
|
||||
'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5),
|
||||
'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
|
||||
'connection_timeout' => 10,
|
||||
'server_interval' => 20,
|
||||
'command_timeout' => 7200,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('local_persistent_volumes', function (Blueprint $table) {
|
||||
$table->dropColumn('is_readonly');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('local_persistent_volumes', function (Blueprint $table) {
|
||||
$table->boolean('is_readonly')->default(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('webhooks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->enum('status', ['pending', 'success', 'failed'])->default('pending');
|
||||
$table->string('type');
|
||||
$table->longText('payload');
|
||||
$table->longText('failure_reason')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('kubernetes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('kubernetes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
// Check if the column exists before trying to drop it
|
||||
if (Schema::hasColumn('environment_variables', 'is_build_time')) {
|
||||
// Drop the is_build_time column
|
||||
// Note: The unique constraints that included is_build_time were tied to old foreign key columns
|
||||
// (application_id, service_id, database_id) which were removed in migration 2024_12_16_134437.
|
||||
// Those constraints should no longer exist in the database.
|
||||
$table->dropColumn('is_build_time');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
// Re-add the is_build_time column
|
||||
if (! Schema::hasColumn('environment_variables', 'is_build_time')) {
|
||||
$table->boolean('is_build_time')->default(false)->after('value');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
$table->boolean('is_buildtime_only')->default(false)->after('is_preview');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
$table->dropColumn('is_buildtime_only');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -10,9 +10,9 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
|
|||
# https://github.com/buildpacks/pack/releases
|
||||
ARG PACK_VERSION=0.38.2
|
||||
# https://github.com/railwayapp/nixpacks/releases
|
||||
ARG NIXPACKS_VERSION=1.39.0
|
||||
ARG NIXPACKS_VERSION=1.40.0
|
||||
# https://github.com/minio/mc/releases
|
||||
ARG MINIO_VERSION=RELEASE.2025-03-12T17-29-24Z
|
||||
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
|
||||
|
||||
FROM minio/mc:${MINIO_VERSION} AS minio-client
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
|
|||
exec </dev/tty
|
||||
fi
|
||||
|
||||
# Generate service templates and OpenAPI documentation
|
||||
echo "🔄 Generating service templates..."
|
||||
php artisan generate:services
|
||||
|
||||
echo "📚 Generating OpenAPI documentation..."
|
||||
php artisan generate:openapi
|
||||
|
||||
# Add the generated files to the commit
|
||||
git add templates/service-templates*.json
|
||||
git add openapi.json openapi.yaml
|
||||
|
||||
echo "✅ Generated files have been added to the commit"
|
||||
|
||||
# Get list of stashed PHP files
|
||||
stashed_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')
|
||||
|
||||
|
|
|
|||
30
openapi.json
30
openapi.json
|
|
@ -2773,10 +2773,6 @@
|
|||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in preview deployments."
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in build time."
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
|
||||
|
|
@ -2870,10 +2866,6 @@
|
|||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in preview deployments."
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in build time."
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
|
||||
|
|
@ -2972,10 +2964,6 @@
|
|||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in preview deployments."
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in build time."
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
|
||||
|
|
@ -7179,10 +7167,6 @@
|
|||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in preview deployments."
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in build time."
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
|
||||
|
|
@ -7276,10 +7260,6 @@
|
|||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in preview deployments."
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in build time."
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
|
||||
|
|
@ -7378,10 +7358,6 @@
|
|||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in preview deployments."
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is used in build time."
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
|
||||
|
|
@ -8375,9 +8351,6 @@
|
|||
"resourceable_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_build_time": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_literal": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -8387,6 +8360,9 @@
|
|||
"is_preview": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_buildtime_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_shared": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
|||
20
openapi.yaml
20
openapi.yaml
|
|
@ -1778,9 +1778,6 @@ paths:
|
|||
is_preview:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in preview deployments.'
|
||||
is_build_time:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in build time.'
|
||||
is_literal:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
|
||||
|
|
@ -1843,9 +1840,6 @@ paths:
|
|||
is_preview:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in preview deployments.'
|
||||
is_build_time:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in build time.'
|
||||
is_literal:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
|
||||
|
|
@ -1901,7 +1895,7 @@ paths:
|
|||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
|
||||
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
|
|
@ -4615,9 +4609,6 @@ paths:
|
|||
is_preview:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in preview deployments.'
|
||||
is_build_time:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in build time.'
|
||||
is_literal:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
|
||||
|
|
@ -4680,9 +4671,6 @@ paths:
|
|||
is_preview:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in preview deployments.'
|
||||
is_build_time:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is used in build time.'
|
||||
is_literal:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
|
||||
|
|
@ -4738,7 +4726,7 @@ paths:
|
|||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
|
||||
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
|
|
@ -5417,14 +5405,14 @@ components:
|
|||
type: string
|
||||
resourceable_id:
|
||||
type: integer
|
||||
is_build_time:
|
||||
type: boolean
|
||||
is_literal:
|
||||
type: boolean
|
||||
is_multiline:
|
||||
type: boolean
|
||||
is_preview:
|
||||
type: boolean
|
||||
is_buildtime_only:
|
||||
type: boolean
|
||||
is_shared:
|
||||
type: boolean
|
||||
is_shown_once:
|
||||
|
|
|
|||
68
package-lock.json
generated
68
package-lock.json
generated
|
|
@ -22,7 +22,7 @@
|
|||
"pusher-js": "8.4.0",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwindcss": "4.1.10",
|
||||
"vite": "6.3.5",
|
||||
"vite": "6.3.6",
|
||||
"vue": "3.5.16"
|
||||
}
|
||||
},
|
||||
|
|
@ -1131,6 +1131,66 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.10",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz",
|
||||
|
|
@ -2635,9 +2695,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"pusher-js": "8.4.0",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwindcss": "4.1.10",
|
||||
"vite": "6.3.5",
|
||||
"vite": "6.3.6",
|
||||
"vue": "3.5.16"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}
|
|||
Teams
|
||||
</a>
|
||||
</li>
|
||||
@if (isCloud())
|
||||
@if (isCloud() && auth()->user()->isAdmin())
|
||||
<li>
|
||||
<a title="Subscription"
|
||||
class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
<form wire:submit='submit' class="flex flex-col pb-32">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>General</h2>
|
||||
@if (isDev())
|
||||
<div>{{ $application->compose_parsing_version }}</div>
|
||||
@endif
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
</div>
|
||||
<div>General configuration for your application.</div>
|
||||
|
|
@ -462,12 +465,9 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-domain-conflict-modal
|
||||
:conflicts="$domainConflicts"
|
||||
:showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage" />
|
||||
|
||||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage" />
|
||||
|
||||
@script
|
||||
<script>
|
||||
$wire.$on('loadCompose', (isInit = true) => {
|
||||
|
|
|
|||
|
|
@ -5,25 +5,25 @@
|
|||
@can('update', $application)
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@endcan
|
||||
<a target="_blank" class="hover:no-underline" href="{{ $application?->gitBranchLocation }}">
|
||||
<x-forms.button>
|
||||
<div class="flex items-center gap-4 px-2">
|
||||
<a target="_blank" class="hover:no-underline flex items-center gap-1"
|
||||
href="{{ $application?->gitBranchLocation }}">
|
||||
Open Repository
|
||||
<x-external-link />
|
||||
</x-forms.button>
|
||||
</a>
|
||||
@if (data_get($application, 'source.is_public') === false)
|
||||
<a target="_blank" class="hover:no-underline" href="{{ getInstallationPath($application->source) }}">
|
||||
<x-forms.button>
|
||||
</a>
|
||||
@if (data_get($application, 'source.is_public') === false)
|
||||
<a target="_blank" class="hover:no-underline flex items-center gap-1"
|
||||
href="{{ getInstallationPath($application->source) }}">
|
||||
Open Git App
|
||||
<x-external-link />
|
||||
</x-forms.button>
|
||||
</a>
|
||||
@endif
|
||||
<a target="_blank" class="flex hover:no-underline" href="{{ $application?->gitCommits }}">
|
||||
<x-forms.button>Open Commits on Git
|
||||
</a>
|
||||
@endif
|
||||
<a target="_blank" class="flex hover:no-underline items-center gap-1"
|
||||
href="{{ $application?->gitCommits }}">
|
||||
Open Commits on Git
|
||||
<x-external-link />
|
||||
</x-forms.button>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-4">Code source of your application.</div>
|
||||
|
||||
|
|
@ -34,11 +34,13 @@ class="font-bold text-warning">{{ data_get($application, 'source.name', 'No sour
|
|||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input placeholder="coollabsio/coolify-example" id="gitRepository" label="Repository" canGate="update" :canResource="$application" />
|
||||
<x-forms.input placeholder="coollabsio/coolify-example" id="gitRepository" label="Repository"
|
||||
canGate="update" :canResource="$application" />
|
||||
<x-forms.input placeholder="main" id="gitBranch" label="Branch" canGate="update" :canResource="$application" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.input placeholder="HEAD" id="gitCommitSha" placeholder="HEAD" label="Commit SHA" canGate="update" :canResource="$application" />
|
||||
<x-forms.input placeholder="HEAD" id="gitCommitSha" placeholder="HEAD" label="Commit SHA"
|
||||
canGate="update" :canResource="$application" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
<x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required />
|
||||
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
|
||||
x-bind:label="$wire.is_multiline === false && 'Value'" required />
|
||||
@if (data_get($parameters, 'application_uuid'))
|
||||
<x-forms.checkbox id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
@endif
|
||||
<x-forms.checkbox id="is_multiline" label="Is Multiline?" />
|
||||
@if (!$shared)
|
||||
<x-forms.checkbox id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
|
|
|
|||
|
|
@ -45,14 +45,7 @@
|
|||
<h3>Production Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Production.</div>
|
||||
</div>
|
||||
@php
|
||||
$requiredEmptyVars = $resource->environment_variables->filter(function ($env) {
|
||||
return $env->is_required && empty($env->value);
|
||||
});
|
||||
|
||||
$otherVars = $resource->environment_variables->diff($requiredEmptyVars);
|
||||
@endphp
|
||||
@forelse ($requiredEmptyVars->merge($otherVars) as $env)
|
||||
@forelse ($this->environmentVariables as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
|
||||
:env="$env" :type="$resource->type()" />
|
||||
@empty
|
||||
|
|
@ -63,7 +56,7 @@
|
|||
<h3>Preview Deployments Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Preview Deployments.</div>
|
||||
</div>
|
||||
@foreach ($resource->environment_variables_preview as $env)
|
||||
@foreach ($this->environmentVariablesPreview as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
|
||||
:env="$env" :type="$resource->type()" />
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -58,18 +58,15 @@
|
|||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox instantSave id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
<x-forms.checkbox instantSave id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox instantSave id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
|
|
@ -77,9 +74,9 @@
|
|||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@else
|
||||
<x-forms.checkbox instantSave id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
<x-forms.checkbox instantSave id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
|
|
@ -123,18 +120,15 @@
|
|||
<div class="flex flex-col w-full gap-2 flex-wrap lg:flex-row">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox disabled id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
<x-forms.checkbox disabled id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox disabled id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
|
|
@ -142,9 +136,9 @@
|
|||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@else
|
||||
<x-forms.checkbox disabled id="is_build_time"
|
||||
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
|
||||
label="Is Build Variable?" />
|
||||
<x-forms.checkbox disabled id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@
|
|||
@if (count($containers) === 0)
|
||||
<div>No containers are running or terminal access is disabled on this server.</div>
|
||||
@else
|
||||
<form class="w-full flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')">
|
||||
<form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"
|
||||
x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) {
|
||||
autoConnected = true;
|
||||
$nextTick(() => $wire.dispatchSelf('connectToContainer'));
|
||||
}">
|
||||
<x-forms.select label="Container" id="container" required wire:model.live="selected_container">
|
||||
@foreach ($containers as $container)
|
||||
@if ($loop->first)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
@foreach ($resource->persistentStorages as $storage)
|
||||
@if ($resource->type() === 'service')
|
||||
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
|
||||
:resource="$resource" :isFirst="$loop->first" isService='true' />
|
||||
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' />
|
||||
@else
|
||||
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
|
||||
:resource="$resource" isReadOnly="{{ data_get($storage, 'is_readonly') }}"
|
||||
startedAt="{{ data_get($resource, 'started_at') }}" />
|
||||
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" />
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -211,6 +211,14 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
:canResource="$server">Save</x-forms.button>
|
||||
<x-forms.button wire:click='restartSentinel' canGate="update"
|
||||
:canResource="$server">Restart</x-forms.button>
|
||||
<x-slide-over fullScreen>
|
||||
<x-slot:title>Sentinel Logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server"
|
||||
container="coolify-sentinel" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
@else
|
||||
<x-status.stopped status="Out of sync" noLoading
|
||||
title="{{ $sentinelUpdatedAt }}" />
|
||||
|
|
@ -218,6 +226,14 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
:canResource="$server">Save</x-forms.button>
|
||||
<x-forms.button wire:click='restartSentinel' canGate="update"
|
||||
:canResource="$server">Sync</x-forms.button>
|
||||
<x-slide-over fullScreen>
|
||||
<x-slot:title>Sentinel Logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server"
|
||||
container="coolify-sentinel" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -243,6 +259,22 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
label="Enable Metrics (enable Sentinel first)" />
|
||||
@endif
|
||||
</div>
|
||||
@if (isDev() && $server->isSentinelEnabled())
|
||||
<div class="pt-4" x-data="{
|
||||
customImage: localStorage.getItem('sentinel_custom_docker_image_{{ $server->uuid }}') || '',
|
||||
saveCustomImage() {
|
||||
localStorage.setItem('sentinel_custom_docker_image_{{ $server->uuid }}', this.customImage);
|
||||
$wire.set('sentinelCustomDockerImage', this.customImage);
|
||||
}
|
||||
}" x-init="$wire.set('sentinelCustomDockerImage', customImage)">
|
||||
<x-forms.input
|
||||
x-model="customImage"
|
||||
@input.debounce.500ms="saveCustomImage()"
|
||||
placeholder="e.g., sentinel:latest or myregistry/sentinel:dev"
|
||||
label="Custom Sentinel Docker Image (Dev Only)"
|
||||
helper="Override the default Sentinel Docker image for testing. Leave empty to use the default." />
|
||||
</div>
|
||||
@endif
|
||||
@if ($server->isSentinelEnabled())
|
||||
<div class="flex flex-wrap gap-2 sm:flex-nowrap items-end">
|
||||
<x-forms.input canGate="update" :canResource="$server" type="password" id="sentinelToken"
|
||||
|
|
|
|||
103
templates/compose/n8n-with-postgres-and-worker.yaml
Normal file
103
templates/compose/n8n-with-postgres-and-worker.yaml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# documentation: https://n8n.io
|
||||
# slogan: n8n is an extendable workflow automation tool with queue mode and workers.
|
||||
# category: automation
|
||||
# tags: n8n,workflow,automation,open,source,low,code,queue,worker,scalable
|
||||
# logo: svgs/n8n.png
|
||||
# port: 5678
|
||||
|
||||
services:
|
||||
n8n:
|
||||
image: docker.n8n.io/n8nio/n8n
|
||||
environment:
|
||||
- SERVICE_URL_N8N_5678
|
||||
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
|
||||
- WEBHOOK_URL=${SERVICE_URL_N8N}
|
||||
- N8N_HOST=${SERVICE_URL_N8N}
|
||||
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin}
|
||||
- TZ=${TZ:-Europe/Berlin}
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
|
||||
- DB_POSTGRESDB_HOST=postgresql
|
||||
- DB_POSTGRESDB_PORT=5432
|
||||
- DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES
|
||||
- DB_POSTGRESDB_SCHEMA=public
|
||||
- DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- QUEUE_HEALTH_CHECK_ACTIVE=true
|
||||
- N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION}
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true
|
||||
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
|
||||
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
|
||||
volumes:
|
||||
- n8n-data:/home/node/.n8n
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/"]
|
||||
interval: 5s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
n8n-worker:
|
||||
image: docker.n8n.io/n8nio/n8n
|
||||
command: worker
|
||||
environment:
|
||||
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin}
|
||||
- TZ=${TZ:-Europe/Berlin}
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
|
||||
- DB_POSTGRESDB_HOST=postgresql
|
||||
- DB_POSTGRESDB_PORT=5432
|
||||
- DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES
|
||||
- DB_POSTGRESDB_SCHEMA=public
|
||||
- DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- QUEUE_HEALTH_CHECK_ACTIVE=true
|
||||
- N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION}
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
|
||||
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
|
||||
volumes:
|
||||
- n8n-data:/home/node/.n8n
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/healthz"]
|
||||
interval: 5s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
depends_on:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
postgresql:
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- postgresql-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=$SERVICE_USER_POSTGRES
|
||||
- POSTGRES_PASSWORD=$SERVICE_PASSWORD_POSTGRES
|
||||
- POSTGRES_DB=${POSTGRES_DB:-n8n}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
316
tests/Unit/PrivateKeyStorageTest.php
Normal file
316
tests/Unit/PrivateKeyStorageTest.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PrivateKeyStorageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Set up a test team for the tests
|
||||
$this->actingAs(\App\Models\User::factory()->create());
|
||||
}
|
||||
|
||||
protected function getValidPrivateKey(): string
|
||||
{
|
||||
return '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_successfully_stores_private_key_in_filesystem()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
$privateKey = PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('private_keys', [
|
||||
'id' => $privateKey->id,
|
||||
'name' => 'Test Key',
|
||||
]);
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
Storage::disk('ssh-keys')->assertExists($filename);
|
||||
|
||||
$storedContent = Storage::disk('ssh-keys')->get($filename);
|
||||
$this->assertEquals($privateKey->private_key, $storedContent);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_throws_exception_when_storage_fails()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
// Mock Storage::put to return false (simulating storage failure)
|
||||
Storage::shouldReceive('disk')
|
||||
->with('ssh-keys')
|
||||
->andReturn(
|
||||
\Mockery::mock()
|
||||
->shouldReceive('exists')
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::any(), 'test')
|
||||
->andReturn(true)
|
||||
->shouldReceive('delete')
|
||||
->with(\Mockery::any())
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any())
|
||||
->andReturn(false) // Simulate storage failure
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Failed to write SSH key to filesystem');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
// Assert that no database record was created due to transaction rollback
|
||||
$this->assertDatabaseMissing('private_keys', [
|
||||
'name' => 'Test Key',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_throws_exception_when_storage_directory_is_not_writable()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
// Mock Storage disk to simulate directory not writable
|
||||
Storage::shouldReceive('disk')
|
||||
->with('ssh-keys')
|
||||
->andReturn(
|
||||
\Mockery::mock()
|
||||
->shouldReceive('exists')
|
||||
->with('')
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/\.test_write_/'), 'test')
|
||||
->andReturn(false) // Simulate directory not writable
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('SSH keys storage directory is not writable');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_creates_storage_directory_if_not_exists()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
// Mock Storage disk to simulate directory not existing, then being created
|
||||
Storage::shouldReceive('disk')
|
||||
->with('ssh-keys')
|
||||
->andReturn(
|
||||
\Mockery::mock()
|
||||
->shouldReceive('exists')
|
||||
->with('')
|
||||
->andReturn(false) // Directory doesn't exist
|
||||
->shouldReceive('makeDirectory')
|
||||
->with('')
|
||||
->andReturn(true) // Successfully create directory
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/\.test_write_/'), 'test')
|
||||
->andReturn(true) // Directory is writable after creation
|
||||
->shouldReceive('delete')
|
||||
->with(\Mockery::pattern('/\.test_write_/'))
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any())
|
||||
->andReturn(true)
|
||||
->shouldReceive('exists')
|
||||
->with(\Mockery::pattern('/ssh_key@/'))
|
||||
->andReturn(true)
|
||||
->shouldReceive('get')
|
||||
->with(\Mockery::pattern('/ssh_key@/'))
|
||||
->andReturn($this->getValidPrivateKey())
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$privateKey = PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('private_keys', [
|
||||
'id' => $privateKey->id,
|
||||
'name' => 'Test Key',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_throws_exception_when_file_content_verification_fails()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
// Mock Storage disk to simulate file being created but with wrong content
|
||||
Storage::shouldReceive('disk')
|
||||
->with('ssh-keys')
|
||||
->andReturn(
|
||||
\Mockery::mock()
|
||||
->shouldReceive('exists')
|
||||
->with('')
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/\.test_write_/'), 'test')
|
||||
->andReturn(true)
|
||||
->shouldReceive('delete')
|
||||
->with(\Mockery::pattern('/\.test_write_/'))
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any())
|
||||
->andReturn(true) // File created successfully
|
||||
->shouldReceive('exists')
|
||||
->with(\Mockery::pattern('/ssh_key@/'))
|
||||
->andReturn(true) // File exists
|
||||
->shouldReceive('get')
|
||||
->with(\Mockery::pattern('/ssh_key@/'))
|
||||
->andReturn('corrupted content') // But content is wrong
|
||||
->shouldReceive('delete')
|
||||
->with(\Mockery::pattern('/ssh_key@/'))
|
||||
->andReturn(true) // Clean up bad file
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('SSH key file content verification failed');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
// Assert that no database record was created due to transaction rollback
|
||||
$this->assertDatabaseMissing('private_keys', [
|
||||
'name' => 'Test Key',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_successfully_deletes_private_key_from_filesystem()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
$privateKey = PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
Storage::disk('ssh-keys')->assertExists($filename);
|
||||
|
||||
$privateKey->delete();
|
||||
|
||||
Storage::disk('ssh-keys')->assertMissing($filename);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_database_transaction_rollback_on_storage_failure()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
// Count initial private keys
|
||||
$initialCount = PrivateKey::count();
|
||||
|
||||
// Mock storage failure after database save
|
||||
Storage::shouldReceive('disk')
|
||||
->with('ssh-keys')
|
||||
->andReturn(
|
||||
\Mockery::mock()
|
||||
->shouldReceive('exists')
|
||||
->with('')
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/\.test_write_/'), 'test')
|
||||
->andReturn(true)
|
||||
->shouldReceive('delete')
|
||||
->with(\Mockery::pattern('/\.test_write_/'))
|
||||
->andReturn(true)
|
||||
->shouldReceive('put')
|
||||
->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any())
|
||||
->andReturn(false) // Storage fails
|
||||
->getMock()
|
||||
);
|
||||
|
||||
try {
|
||||
PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Expected exception
|
||||
}
|
||||
|
||||
// Assert that database was rolled back
|
||||
$this->assertEquals($initialCount, PrivateKey::count());
|
||||
$this->assertDatabaseMissing('private_keys', [
|
||||
'name' => 'Test Key',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_successfully_updates_private_key_with_transaction()
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
$privateKey = PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test Description',
|
||||
'private_key' => $this->getValidPrivateKey(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$newPrivateKey = str_replace('Test', 'Updated', $this->getValidPrivateKey());
|
||||
|
||||
$privateKey->updatePrivateKey([
|
||||
'name' => 'Updated Key',
|
||||
'private_key' => $newPrivateKey,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('private_keys', [
|
||||
'id' => $privateKey->id,
|
||||
'name' => 'Updated Key',
|
||||
]);
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
$storedContent = Storage::disk('ssh-keys')->get($filename);
|
||||
$this->assertEquals($newPrivateKey, $storedContent);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue