mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 22:14:53 +00:00
Merge remote-tracking branch 'origin/next' into fix/preview-deployments-invisible
This commit is contained in:
commit
380a34c7d6
173 files changed
+10281
-1697
No files matched your search
@@ -30,12 +30,14 @@ class InstallDocker
|
||||
);
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($serverCert->ssl_certificate);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
remote_process($commands, $server);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscriptionAtPeriodEnd
|
||||
{
|
||||
private StripeClient $stripe;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the team's subscription at the end of the current billing period.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return ['success' => false, 'error' => 'No active subscription found.'];
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
return ['success' => false, 'error' => 'Subscription is not active.'];
|
||||
}
|
||||
|
||||
if ($subscription->stripe_cancel_at_period_end) {
|
||||
return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'cancel_at_period_end' => true,
|
||||
]);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => true,
|
||||
]);
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class RefundSubscription
|
||||
{
|
||||
private StripeClient $stripe;
|
||||
|
||||
private const REFUND_WINDOW_DAYS = 30;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the team's subscription is eligible for a refund.
|
||||
*
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string}
|
||||
*/
|
||||
public function checkEligibility(Team $team): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if ($subscription?->stripe_refunded_at) {
|
||||
return $this->ineligible('A refund has already been processed for this team.');
|
||||
}
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return $this->ineligible('No active subscription found.');
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
return $this->ineligible('Subscription invoice is not paid.');
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
return $this->ineligible('Subscription not found in Stripe.');
|
||||
}
|
||||
|
||||
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
|
||||
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
|
||||
}
|
||||
|
||||
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
|
||||
$daysSinceStart = (int) $startDate->diffInDays(now());
|
||||
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
||||
|
||||
if ($daysRemaining <= 0) {
|
||||
return $this->ineligible('The 30-day refund window has expired.');
|
||||
}
|
||||
|
||||
return [
|
||||
'eligible' => true,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'reason' => 'Eligible for refund.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a full refund and cancel the subscription.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team): array
|
||||
{
|
||||
$eligibility = $this->checkEligibility($team);
|
||||
|
||||
if (! $eligibility['eligible']) {
|
||||
return ['success' => false, 'error' => $eligibility['reason']];
|
||||
}
|
||||
|
||||
$subscription = $team->subscription;
|
||||
|
||||
try {
|
||||
$invoices = $this->stripe->invoices->all([
|
||||
'subscription' => $subscription->stripe_subscription_id,
|
||||
'status' => 'paid',
|
||||
'limit' => 1,
|
||||
]);
|
||||
|
||||
if (empty($invoices->data)) {
|
||||
return ['success' => false, 'error' => 'No paid invoice found to refund.'];
|
||||
}
|
||||
|
||||
$invoice = $invoices->data[0];
|
||||
$paymentIntentId = $invoice->payment_intent;
|
||||
|
||||
if (! $paymentIntentId) {
|
||||
return ['success' => false, 'error' => 'No payment intent found on the invoice.'];
|
||||
}
|
||||
|
||||
$this->stripe->refunds->create([
|
||||
'payment_intent' => $paymentIntentId,
|
||||
]);
|
||||
|
||||
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'Refund requested by user',
|
||||
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
|
||||
'stripe_refunded_at' => now(),
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Refund error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string}
|
||||
*/
|
||||
private function ineligible(string $reason): array
|
||||
{
|
||||
return [
|
||||
'eligible' => false,
|
||||
'days_remaining' => 0,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class ResumeSubscription
|
||||
{
|
||||
private StripeClient $stripe;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a subscription that was set to cancel at the end of the billing period.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return ['success' => false, 'error' => 'No active subscription found.'];
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_cancel_at_period_end) {
|
||||
return ['success' => false, 'error' => 'Subscription is not set to cancel.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class UpdateSubscriptionQuantity
|
||||
{
|
||||
public const int MAX_SERVER_LIMIT = 100;
|
||||
|
||||
public const int MIN_SERVER_LIMIT = 2;
|
||||
|
||||
private StripeClient $stripe;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a full price preview for a quantity change from Stripe.
|
||||
* Returns both the prorated amount due now and the recurring cost for the next billing cycle.
|
||||
*
|
||||
* @return array{success: bool, error: string|null, preview: array{due_now: int, recurring_subtotal: int, recurring_tax: int, recurring_total: int, unit_price: int, tax_description: string|null, quantity: int, currency: string}|null}
|
||||
*/
|
||||
public function fetchPricePreview(Team $team, int $quantity): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id || ! $subscription->stripe_invoice_paid) {
|
||||
return ['success' => false, 'error' => 'No active subscription found.', 'preview' => null];
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
$item = $stripeSubscription->items->data[0] ?? null;
|
||||
|
||||
if (! $item) {
|
||||
return ['success' => false, 'error' => 'Could not retrieve subscription details.', 'preview' => null];
|
||||
}
|
||||
|
||||
$currency = strtoupper($item->price->currency ?? 'usd');
|
||||
|
||||
// Upcoming invoice gives us the prorated amount due now
|
||||
$upcomingInvoice = $this->stripe->invoices->upcoming([
|
||||
'customer' => $subscription->stripe_customer_id,
|
||||
'subscription' => $subscription->stripe_subscription_id,
|
||||
'subscription_items' => [
|
||||
['id' => $item->id, 'quantity' => $quantity],
|
||||
],
|
||||
'subscription_proration_behavior' => 'create_prorations',
|
||||
]);
|
||||
|
||||
// Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal
|
||||
$taxPercentage = 0.0;
|
||||
$taxDescription = null;
|
||||
if (! empty($upcomingInvoice->total_tax_amounts)) {
|
||||
$taxAmount = $upcomingInvoice->total_tax_amounts[0] ?? null;
|
||||
if ($taxAmount?->tax_rate) {
|
||||
$taxRate = $this->stripe->taxRates->retrieve($taxAmount->tax_rate);
|
||||
$taxPercentage = (float) ($taxRate->percentage ?? 0);
|
||||
$taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%';
|
||||
}
|
||||
}
|
||||
// Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy
|
||||
if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) {
|
||||
$taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2);
|
||||
}
|
||||
|
||||
// Recurring cost for next cycle — read from non-proration invoice lines
|
||||
$recurringSubtotal = 0;
|
||||
foreach ($upcomingInvoice->lines->data as $line) {
|
||||
if (! $line->proration) {
|
||||
$recurringSubtotal += $line->amount;
|
||||
}
|
||||
}
|
||||
$unitPrice = $quantity > 0 ? (int) round($recurringSubtotal / $quantity) : 0;
|
||||
|
||||
$recurringTax = $taxPercentage > 0
|
||||
? (int) round($recurringSubtotal * $taxPercentage / 100)
|
||||
: 0;
|
||||
$recurringTotal = $recurringSubtotal + $recurringTax;
|
||||
|
||||
// Due now = amount_due (accounts for customer balance/credits) minus recurring
|
||||
$amountDue = $upcomingInvoice->amount_due ?? $upcomingInvoice->total ?? 0;
|
||||
$dueNow = $amountDue - $recurringTotal;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error' => null,
|
||||
'preview' => [
|
||||
'due_now' => $dueNow,
|
||||
'recurring_subtotal' => $recurringSubtotal,
|
||||
'recurring_tax' => $recurringTax,
|
||||
'recurring_total' => $recurringTotal,
|
||||
'unit_price' => $unitPrice,
|
||||
'tax_description' => $taxDescription,
|
||||
'quantity' => $quantity,
|
||||
'currency' => $currency,
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning("Stripe fetch price preview error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Could not load price preview.', 'preview' => null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the subscription quantity (server limit) for a team.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team, int $quantity): array
|
||||
{
|
||||
if ($quantity < self::MIN_SERVER_LIMIT) {
|
||||
return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.'];
|
||||
}
|
||||
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return ['success' => false, 'error' => 'No active subscription found.'];
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
return ['success' => false, 'error' => 'Subscription is not active.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
$item = $stripeSubscription->items->data[0] ?? null;
|
||||
|
||||
if (! $item?->id) {
|
||||
return ['success' => false, 'error' => 'Could not find subscription item.'];
|
||||
}
|
||||
|
||||
$previousQuantity = $item->quantity ?? $team->custom_server_limit;
|
||||
|
||||
$updatedSubscription = $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'items' => [
|
||||
['id' => $item->id, 'quantity' => $quantity],
|
||||
],
|
||||
'proration_behavior' => 'always_invoice',
|
||||
'expand' => ['latest_invoice'],
|
||||
]);
|
||||
|
||||
// Check if the proration invoice was paid
|
||||
$latestInvoice = $updatedSubscription->latest_invoice;
|
||||
if ($latestInvoice && $latestInvoice->status !== 'paid') {
|
||||
\Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
|
||||
|
||||
// Revert subscription quantity on Stripe
|
||||
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'items' => [
|
||||
['id' => $item->id, 'quantity' => $previousQuantity],
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
]);
|
||||
|
||||
// Void the unpaid invoice
|
||||
if ($latestInvoice->id) {
|
||||
$this->stripe->invoices->voidInvoice($latestInvoice->id);
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.'];
|
||||
}
|
||||
|
||||
$team->update([
|
||||
'custom_server_limit' => $quantity,
|
||||
]);
|
||||
|
||||
ServerLimitCheckJob::dispatch($team);
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Update subscription quantity error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
|
||||
private function formatAmount(int $cents, string $currency): string
|
||||
{
|
||||
return strtoupper($currency) === 'USD'
|
||||
? '$'.number_format($cents / 100, 2)
|
||||
: number_format($cents / 100, 2).' '.$currency;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class CleanupUnreachableServers extends Command
|
||||
public function handle()
|
||||
{
|
||||
echo "Running unreachable server cleanup...\n";
|
||||
$servers = Server::where('unreachable_count', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get();
|
||||
$servers = Server::where('unreachable_count', '>=', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get();
|
||||
if ($servers->count() > 0) {
|
||||
foreach ($servers as $server) {
|
||||
echo "Cleanup unreachable server ($server->id) with name $server->name";
|
||||
|
||||
@@ -36,7 +36,14 @@ class SyncStripeSubscriptions extends Command
|
||||
$this->newLine();
|
||||
|
||||
$job = new SyncStripeSubscriptionsJob($fix);
|
||||
$result = $job->handle();
|
||||
$fetched = 0;
|
||||
$result = $job->handle(function (int $count) use (&$fetched): void {
|
||||
$fetched = $count;
|
||||
$this->output->write("\r Fetching subscriptions from Stripe... {$fetched}");
|
||||
});
|
||||
if ($fetched > 0) {
|
||||
$this->output->write("\r".str_repeat(' ', 60)."\r");
|
||||
}
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$this->error($result['error']);
|
||||
@@ -68,6 +75,19 @@ class SyncStripeSubscriptions extends Command
|
||||
$this->info('No discrepancies found. All subscriptions are in sync.');
|
||||
}
|
||||
|
||||
if (count($result['resubscribed']) > 0) {
|
||||
$this->newLine();
|
||||
$this->warn('Resubscribed users (same email, different customer): '.count($result['resubscribed']));
|
||||
$this->newLine();
|
||||
|
||||
foreach ($result['resubscribed'] as $resub) {
|
||||
$this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}");
|
||||
$this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})");
|
||||
$this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]");
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (count($result['errors']) > 0) {
|
||||
$this->newLine();
|
||||
$this->error('Errors encountered: '.count($result['errors']));
|
||||
|
||||
@@ -40,7 +40,7 @@ class Kernel extends ConsoleKernel
|
||||
}
|
||||
|
||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||
$this->scheduleInstance->command('cleanup:redis')->weekly();
|
||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
|
||||
@@ -37,7 +37,7 @@ class SshMultiplexingHelper
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$checkCommand .= "{$server->user}@{$server->ip}";
|
||||
$checkCommand .= self::escapedUserAtHost($server);
|
||||
$process = Process::run($checkCommand);
|
||||
|
||||
if ($process->exitCode() !== 0) {
|
||||
@@ -80,7 +80,7 @@ class SshMultiplexingHelper
|
||||
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
||||
$establishCommand .= "{$server->user}@{$server->ip}";
|
||||
$establishCommand .= self::escapedUserAtHost($server);
|
||||
$establishProcess = Process::run($establishCommand);
|
||||
if ($establishProcess->exitCode() !== 0) {
|
||||
return false;
|
||||
@@ -101,7 +101,7 @@ class SshMultiplexingHelper
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$closeCommand .= "{$server->user}@{$server->ip}";
|
||||
$closeCommand .= self::escapedUserAtHost($server);
|
||||
Process::run($closeCommand);
|
||||
|
||||
// Clear connection metadata from cache
|
||||
@@ -141,9 +141,9 @@ class SshMultiplexingHelper
|
||||
|
||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}";
|
||||
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
||||
} else {
|
||||
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
|
||||
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
|
||||
}
|
||||
|
||||
return $scp_command;
|
||||
@@ -189,13 +189,18 @@ class SshMultiplexingHelper
|
||||
$delimiter = base64_encode($delimiter);
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
|
||||
$ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL
|
||||
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
|
||||
return $ssh_command;
|
||||
}
|
||||
|
||||
private static function escapedUserAtHost(Server $server): string
|
||||
{
|
||||
return escapeshellarg($server->user).'@'.escapeshellarg($server->ip);
|
||||
}
|
||||
|
||||
private static function isMultiplexingEnabled(): bool
|
||||
{
|
||||
return config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop');
|
||||
@@ -224,9 +229,9 @@ class SshMultiplexingHelper
|
||||
|
||||
// Bruh
|
||||
if ($isScp) {
|
||||
$options .= "-P {$server->port} ";
|
||||
$options .= '-P '.escapeshellarg((string) $server->port).' ';
|
||||
} else {
|
||||
$options .= "-p {$server->port} ";
|
||||
$options .= '-p '.escapeshellarg((string) $server->port).' ';
|
||||
}
|
||||
|
||||
return $options;
|
||||
@@ -245,7 +250,7 @@ class SshMultiplexingHelper
|
||||
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\"'";
|
||||
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||
|
||||
$process = Process::run($healthCommand);
|
||||
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
|
||||
@@ -1002,7 +1002,7 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
@@ -1095,6 +1095,17 @@ class ApplicationsController extends Controller
|
||||
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
|
||||
}
|
||||
$destination = $destinations->first();
|
||||
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
|
||||
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
if ($type === 'public') {
|
||||
$validationRules = [
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
@@ -2460,7 +2471,7 @@ class ApplicationsController extends Controller
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$server = $application->destination->server;
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
|
||||
$validationRules = [
|
||||
'name' => 'string|max:255',
|
||||
@@ -2936,7 +2947,7 @@ class ApplicationsController extends Controller
|
||||
)]
|
||||
public function update_env_by_uuid(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
@@ -2966,6 +2977,7 @@ class ApplicationsController extends Controller
|
||||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -3007,6 +3019,9 @@ class ApplicationsController extends Controller
|
||||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
if ($request->has('comment') && $env->comment != $request->comment) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
@@ -3037,6 +3052,9 @@ class ApplicationsController extends Controller
|
||||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
if ($request->has('comment') && $env->comment != $request->comment) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
@@ -3329,7 +3347,7 @@ class ApplicationsController extends Controller
|
||||
)]
|
||||
public function create_env(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
@@ -3354,6 +3372,7 @@ class ApplicationsController extends Controller
|
||||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -3389,6 +3408,7 @@ class ApplicationsController extends Controller
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'comment' => $request->comment ?? null,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -3413,6 +3433,7 @@ class ApplicationsController extends Controller
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'comment' => $request->comment ?? null,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Application;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Rules\ValidServerIp;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Stringable;
|
||||
@@ -290,9 +291,12 @@ class ServersController extends Controller
|
||||
}
|
||||
$uuid = $request->get('uuid');
|
||||
if ($uuid) {
|
||||
$domains = Application::getDomainsByUuid($uuid);
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(serializeApiResponse($domains));
|
||||
return response()->json(serializeApiResponse($application->fqdns));
|
||||
}
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$domains = collect();
|
||||
@@ -469,10 +473,10 @@ class ServersController extends Controller
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
'description' => 'string|nullable',
|
||||
'ip' => 'string|required',
|
||||
'port' => 'integer|nullable',
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'user' => 'string|nullable',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
@@ -634,10 +638,10 @@ class ServersController extends Controller
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|nullable',
|
||||
'description' => 'string|nullable',
|
||||
'ip' => 'string|nullable',
|
||||
'port' => 'integer|nullable',
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|nullable',
|
||||
'user' => 'string|nullable',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
|
||||
@@ -377,6 +377,17 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
|
||||
}
|
||||
$destination = $destinations->first();
|
||||
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
|
||||
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
$services = get_service_templates();
|
||||
$serviceKeys = $services->keys();
|
||||
if ($serviceKeys->contains($request->type)) {
|
||||
@@ -543,6 +554,17 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
|
||||
}
|
||||
$destination = $destinations->first();
|
||||
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
|
||||
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
if (! isBase64Encoded($request->docker_compose_raw)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -1184,6 +1206,7 @@ class ServicesController extends Controller
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -1199,7 +1222,19 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$env->fill($request->all());
|
||||
$env->value = $request->value;
|
||||
if ($request->has('is_literal')) {
|
||||
$env->is_literal = $request->is_literal;
|
||||
}
|
||||
if ($request->has('is_multiline')) {
|
||||
$env->is_multiline = $request->is_multiline;
|
||||
}
|
||||
if ($request->has('is_shown_once')) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('comment')) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
@@ -1425,6 +1460,7 @@ class ServicesController extends Controller
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -1442,7 +1478,14 @@ class ServicesController extends Controller
|
||||
], 409);
|
||||
}
|
||||
|
||||
$env = $service->environment_variables()->create($request->all());
|
||||
$env = $service->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $request->value,
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'comment' => $request->comment ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class CheckForcePasswordReset
|
||||
}
|
||||
$force_password_reset = auth()->user()->force_password_reset;
|
||||
if ($force_password_reset) {
|
||||
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
|
||||
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,13 @@ class TrustHosts extends Middleware
|
||||
// Trust all subdomains of APP_URL as fallback
|
||||
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
|
||||
|
||||
// Always trust loopback addresses so local access works even when FQDN is configured
|
||||
foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) {
|
||||
if (! in_array($localHost, $trustedHosts, true)) {
|
||||
$trustedHosts[] = $localHost;
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($trustedHosts);
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,26 @@ class TrustProxies extends Middleware
|
||||
Request::HEADER_X_FORWARDED_PORT |
|
||||
Request::HEADER_X_FORWARDED_PROTO |
|
||||
Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||
|
||||
/**
|
||||
* Handle the request.
|
||||
*
|
||||
* Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed),
|
||||
* the Secure cookie flag is auto-enabled when the request is over HTTPS.
|
||||
* This ensures session cookies are correctly marked Secure when behind an HTTPS
|
||||
* reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE
|
||||
* is not explicitly set in .env.
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
return parent::handle($request, function ($request) use ($next) {
|
||||
// At this point proxy headers have been applied to the request,
|
||||
// so $request->secure() correctly reflects the actual protocol.
|
||||
if ($request->secure() && config('session.secure') === null) {
|
||||
config(['session.secure' => true]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -686,8 +686,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Inject build arguments after build subcommand if not using build secrets
|
||||
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
||||
$build_args_string = $this->build_args->implode(' ');
|
||||
// Escape single quotes for bash -c context used by executeInDocker
|
||||
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
||||
|
||||
// Inject build args right after 'build' subcommand (not at the end)
|
||||
$original_command = $build_command;
|
||||
@@ -699,9 +697,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
||||
);
|
||||
try {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
||||
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
||||
@@ -718,8 +724,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
||||
$build_args_string = $this->build_args->implode(' ');
|
||||
// Escape single quotes for bash -c context used by executeInDocker
|
||||
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
||||
$command .= " {$build_args_string}";
|
||||
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
|
||||
}
|
||||
@@ -765,9 +769,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
);
|
||||
|
||||
$this->write_deployment_configurations();
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
|
||||
try {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
||||
throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
$this->docker_compose_location = '/docker-compose.yaml';
|
||||
@@ -1797,7 +1810,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
|
||||
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
@@ -2755,29 +2769,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function generate_healthcheck_commands()
|
||||
{
|
||||
// Handle CMD type healthcheck
|
||||
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
||||
$this->full_healthcheck_url = $this->application->health_check_command;
|
||||
|
||||
return $this->application->health_check_command;
|
||||
}
|
||||
|
||||
// HTTP type healthcheck (default)
|
||||
if (! $this->application->health_check_port) {
|
||||
$health_check_port = $this->application->ports_exposes_array[0];
|
||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||
} else {
|
||||
$health_check_port = $this->application->health_check_port;
|
||||
$health_check_port = (int) $this->application->health_check_port;
|
||||
}
|
||||
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
|
||||
$health_check_port = 80;
|
||||
}
|
||||
if ($this->application->health_check_path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
|
||||
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
|
||||
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
|
||||
$path = $this->application->health_check_path
|
||||
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
|
||||
: null;
|
||||
|
||||
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
|
||||
$method = escapeshellarg($method);
|
||||
|
||||
if ($path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
||||
} else {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
|
||||
];
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
|
||||
}
|
||||
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
return implode(' ', $generated_healthchecks_commands);
|
||||
}
|
||||
|
||||
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
|
||||
{
|
||||
if (preg_match($pattern, $value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function pull_latest_image($image)
|
||||
{
|
||||
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
|
||||
|
||||
@@ -111,6 +111,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
$status = str(data_get($this->database, 'status'));
|
||||
if (! $status->startsWith('running') && $this->database->id !== 0) {
|
||||
Log::info('DatabaseBackupJob skipped: database not running', [
|
||||
'backup_id' => $this->backup->id,
|
||||
'database_id' => $this->database->id,
|
||||
'status' => (string) $status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
|
||||
@@ -472,7 +478,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
|
||||
}
|
||||
}
|
||||
\Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
|
||||
Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
|
||||
if ($databaseWithCollections === 'all') {
|
||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||
|
||||
@@ -91,6 +91,8 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
$this->server->team?->notify(new DockerCleanupSuccess($this->server, $message));
|
||||
event(new DockerCleanupDone($this->execution_log));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
|
||||
|
||||
@@ -24,6 +24,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
@@ -130,7 +131,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
$this->containers = collect(data_get($data, 'containers'));
|
||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
|
||||
// Only dispatch storage check when disk percentage actually changes
|
||||
$storageCacheKey = 'storage-check:'.$this->server->id;
|
||||
$lastPercentage = Cache::get($storageCacheKey);
|
||||
if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
|
||||
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
}
|
||||
|
||||
if ($this->containers->isEmpty()) {
|
||||
return;
|
||||
@@ -207,7 +215,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$serviceId = $labels->get('coolify.serviceId');
|
||||
$subType = $labels->get('coolify.service.subType');
|
||||
$subId = $labels->get('coolify.service.subId');
|
||||
if (empty($subId)) {
|
||||
if (empty(trim((string) $subId))) {
|
||||
continue;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
@@ -327,6 +335,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
// Parse key: serviceId:subType:subId
|
||||
[$serviceId, $subType, $subId] = explode(':', $key);
|
||||
|
||||
if (empty($subId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$service = $this->services->where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
@@ -335,9 +347,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||
$subResource = null;
|
||||
if ($subType === 'application') {
|
||||
$subResource = $service->applications()->where('id', $subId)->first();
|
||||
$subResource = $service->applications->where('id', $subId)->first();
|
||||
} elseif ($subType === 'database') {
|
||||
$subResource = $service->databases()->where('id', $subId)->first();
|
||||
$subResource = $service->databases->where('id', $subId)->first();
|
||||
}
|
||||
|
||||
if (! $subResource) {
|
||||
@@ -476,8 +488,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
// Connect proxy to networks asynchronously to avoid blocking the status update
|
||||
ConnectProxyToNetworksJob::dispatch($this->server);
|
||||
// Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
|
||||
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
|
||||
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
|
||||
if (! Cache::has($proxyCacheKey)) {
|
||||
Cache::put($proxyCacheKey, true, 600);
|
||||
ConnectProxyToNetworksJob::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,7 +562,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
return;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$application = $service->applications()->where('id', $subId)->first();
|
||||
$application = $service->applications->where('id', $subId)->first();
|
||||
if ($application) {
|
||||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
@@ -553,7 +570,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
}
|
||||
}
|
||||
} elseif ($subType === 'database') {
|
||||
$database = $service->databases()->where('id', $subId)->first();
|
||||
$database = $service->databases->where('id', $subId)->first();
|
||||
if ($database) {
|
||||
if ($database->status !== $containerStatus) {
|
||||
$database->status = $containerStatus;
|
||||
|
||||
@@ -15,7 +15,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class ScheduledJobManager implements ShouldQueue
|
||||
{
|
||||
@@ -54,6 +56,11 @@ class ScheduledJobManager implements ShouldQueue
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
// Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
|
||||
// Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
|
||||
// @see https://github.com/coollabsio/coolify/issues/8327
|
||||
self::clearStaleLockIfPresent();
|
||||
|
||||
return [
|
||||
(new WithoutOverlapping('scheduled-job-manager'))
|
||||
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
|
||||
@@ -61,6 +68,34 @@ class ScheduledJobManager implements ShouldQueue
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
|
||||
*
|
||||
* This provides continuous self-healing since it runs every time the job is dispatched.
|
||||
* Stale locks permanently block all scheduled job executions with no user-visible error.
|
||||
*/
|
||||
private static function clearStaleLockIfPresent(): void
|
||||
{
|
||||
try {
|
||||
$cachePrefix = config('cache.prefix', '');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager';
|
||||
|
||||
$ttl = Redis::connection('default')->ttl($lockKey);
|
||||
|
||||
if ($ttl === -1) {
|
||||
Redis::connection('default')->del($lockKey);
|
||||
Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
|
||||
'lock_key' => $lockKey,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Never let lock cleanup failure prevent the job from running
|
||||
Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Freeze the execution time at the start of the job
|
||||
@@ -108,6 +143,13 @@ class ScheduledJobManager implements ShouldQueue
|
||||
'dispatched' => $this->dispatchedCount,
|
||||
'skipped' => $this->skippedCount,
|
||||
]);
|
||||
|
||||
// Write heartbeat so the UI can detect when the scheduler has stopped
|
||||
try {
|
||||
Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
|
||||
} catch (\Throwable) {
|
||||
// Non-critical; don't let heartbeat failure affect the job
|
||||
}
|
||||
}
|
||||
|
||||
private function processScheduledBackups(): void
|
||||
@@ -118,7 +160,8 @@ class ScheduledJobManager implements ShouldQueue
|
||||
|
||||
foreach ($backups as $backup) {
|
||||
try {
|
||||
$skipReason = $this->getBackupSkipReason($backup);
|
||||
$server = $backup->server();
|
||||
$skipReason = $this->getBackupSkipReason($backup, $server);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('backup', $skipReason, [
|
||||
@@ -131,7 +174,6 @@ class ScheduledJobManager implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$server = $backup->server();
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
@@ -143,7 +185,7 @@ class ScheduledJobManager implements ShouldQueue
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||
if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) {
|
||||
DatabaseBackupJob::dispatch($backup);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Backup dispatched', [
|
||||
@@ -171,19 +213,21 @@ class ScheduledJobManager implements ShouldQueue
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
try {
|
||||
$skipReason = $this->getTaskSkipReason($task);
|
||||
if ($skipReason !== null) {
|
||||
$server = $task->server();
|
||||
|
||||
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
|
||||
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
|
||||
if ($criticalSkip !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('task', $skipReason, [
|
||||
$this->logSkip('task', $criticalSkip, [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $task->server()?->team_id,
|
||||
'team_id' => $server?->team_id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$server = $task->server();
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
@@ -195,16 +239,31 @@ class ScheduledJobManager implements ShouldQueue
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||
ScheduledTaskJob::dispatch($task);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Task dispatched', [
|
||||
if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
|
||||
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
|
||||
if ($runtimeSkip !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('task', $runtimeSkip, [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ScheduledTaskJob::dispatch($task);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Task dispatched', [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing task', [
|
||||
'task_id' => $task->id,
|
||||
@@ -214,7 +273,7 @@ class ScheduledJobManager implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string
|
||||
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
|
||||
{
|
||||
if (blank(data_get($backup, 'database'))) {
|
||||
$backup->delete();
|
||||
@@ -222,7 +281,6 @@ class ScheduledJobManager implements ShouldQueue
|
||||
return 'database_deleted';
|
||||
}
|
||||
|
||||
$server = $backup->server();
|
||||
if (blank($server)) {
|
||||
$backup->delete();
|
||||
|
||||
@@ -240,12 +298,8 @@ class ScheduledJobManager implements ShouldQueue
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getTaskSkipReason(ScheduledTask $task): ?string
|
||||
private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string
|
||||
{
|
||||
$service = $task->service;
|
||||
$application = $task->application;
|
||||
|
||||
$server = $task->server();
|
||||
if (blank($server)) {
|
||||
$task->delete();
|
||||
|
||||
@@ -260,33 +314,71 @@ class ScheduledJobManager implements ShouldQueue
|
||||
return 'subscription_unpaid';
|
||||
}
|
||||
|
||||
if (! $service && ! $application) {
|
||||
if (! $task->service && ! $task->application) {
|
||||
$task->delete();
|
||||
|
||||
return 'resource_deleted';
|
||||
}
|
||||
|
||||
if ($application && str($application->status)->contains('running') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
|
||||
{
|
||||
if ($task->application && str($task->application->status)->contains('running') === false) {
|
||||
return 'application_not_running';
|
||||
}
|
||||
|
||||
if ($service && str($service->status)->contains('running') === false) {
|
||||
if ($task->service && str($task->service->status)->contains('running') === false) {
|
||||
return 'service_not_running';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function shouldRunNow(string $frequency, string $timezone): bool
|
||||
/**
|
||||
* Determine if a cron schedule should run now.
|
||||
*
|
||||
* When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
|
||||
* instead of isDue(). This is resilient to queue delays — even if the job is delayed
|
||||
* by minutes, it still catches the missed cron window. Without dedupKey, falls back
|
||||
* to simple isDue() check.
|
||||
*/
|
||||
private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool
|
||||
{
|
||||
$cron = new CronExpression($frequency);
|
||||
|
||||
// Use the frozen execution time, not the current time
|
||||
// Fallback to current time if execution time is not set (shouldn't happen)
|
||||
$baseTime = $this->executionTime ?? Carbon::now();
|
||||
$executionTime = $baseTime->copy()->setTimezone($timezone);
|
||||
|
||||
return $cron->isDue($executionTime);
|
||||
// No dedup key → simple isDue check (used by docker cleanups)
|
||||
if ($dedupKey === null) {
|
||||
return $cron->isDue($executionTime);
|
||||
}
|
||||
|
||||
// Get the most recent time this cron was due (including current minute)
|
||||
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
|
||||
$lastDispatched = Cache::get($dedupKey);
|
||||
|
||||
if ($lastDispatched === null) {
|
||||
// First run after restart or cache loss: only fire if actually due right now.
|
||||
// Seed the cache so subsequent runs can use tolerance/catch-up logic.
|
||||
$isDue = $cron->isDue($executionTime);
|
||||
if ($isDue) {
|
||||
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
|
||||
}
|
||||
|
||||
return $isDue;
|
||||
}
|
||||
|
||||
// Subsequent runs: fire if there's been a due time since last dispatch
|
||||
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
|
||||
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function processDockerCleanups(): void
|
||||
|
||||
@@ -14,13 +14,14 @@ use App\Notifications\ScheduledTask\TaskFailed;
|
||||
use App\Notifications\ScheduledTask\TaskSuccess;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScheduledTaskJob implements ShouldQueue
|
||||
class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$server->forceDisableServer();
|
||||
$this->team->notify(new ForceDisabled($server));
|
||||
});
|
||||
} elseif ($number_of_servers_to_disable === 0) {
|
||||
} elseif ($number_of_servers_to_disable <= 0) {
|
||||
$servers->each(function ($server) {
|
||||
if ($server->isForceDisabled()) {
|
||||
$server->forceEnableServer();
|
||||
|
||||
@@ -64,11 +64,11 @@ class ServerManagerJob implements ShouldQueue
|
||||
|
||||
private function getServers(): Collection
|
||||
{
|
||||
$allServers = Server::where('ip', '!=', '1.2.3.4');
|
||||
$allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4');
|
||||
|
||||
if (isCloud()) {
|
||||
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||
$own = Team::find(0)->servers;
|
||||
$own = Team::find(0)->servers()->with('settings')->get();
|
||||
|
||||
return $servers->merge($own);
|
||||
} else {
|
||||
@@ -82,6 +82,10 @@ class ServerManagerJob implements ShouldQueue
|
||||
if ($this->shouldRunNow($this->checkFrequency)) {
|
||||
$servers->each(function (Server $server) {
|
||||
try {
|
||||
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
|
||||
if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
|
||||
return;
|
||||
}
|
||||
ServerConnectionCheckJob::dispatch($server);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
||||
@@ -134,9 +138,7 @@ class ServerManagerJob implements ShouldQueue
|
||||
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||
|
||||
if ($shouldRestartSentinel) {
|
||||
dispatch(function () use ($server) {
|
||||
$server->restartContainer('coolify-sentinel');
|
||||
});
|
||||
CheckAndStartSentinelJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
|
||||
@@ -160,11 +162,8 @@ class ServerManagerJob implements ShouldQueue
|
||||
ServerPatchCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Sentinel update checks (hourly) - check for updates to Sentinel version
|
||||
// No timezone needed for hourly - runs at top of every hour
|
||||
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
|
||||
CheckAndStartSentinelJob::dispatch($server);
|
||||
}
|
||||
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
|
||||
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
|
||||
}
|
||||
|
||||
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
|
||||
|
||||
@@ -22,7 +22,7 @@ class SyncStripeSubscriptionsJob implements ShouldQueue
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
public function handle(?\Closure $onProgress = null): array
|
||||
{
|
||||
if (! isCloud() || ! isStripe()) {
|
||||
return ['error' => 'Not running on Cloud or Stripe not configured'];
|
||||
@@ -33,48 +33,73 @@ class SyncStripeSubscriptionsJob implements ShouldQueue
|
||||
->get();
|
||||
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
|
||||
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
|
||||
|
||||
// Find DB subscriptions not in the valid set
|
||||
$staleSubscriptions = $subscriptions->filter(
|
||||
fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
|
||||
);
|
||||
|
||||
// For each stale subscription, get the exact Stripe status and check for resubscriptions
|
||||
$discrepancies = [];
|
||||
$resubscribed = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
foreach ($staleSubscriptions as $subscription) {
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
$stripeStatus = $stripeSubscription->status;
|
||||
|
||||
// Check if Stripe says cancelled but we think it's active
|
||||
if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) {
|
||||
$discrepancies[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
'team_id' => $subscription->team_id,
|
||||
'stripe_subscription_id' => $subscription->stripe_subscription_id,
|
||||
'stripe_status' => $stripeSubscription->status,
|
||||
];
|
||||
|
||||
// Only fix if --fix flag is passed
|
||||
if ($this->fix) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
if ($stripeSubscription->status === 'canceled') {
|
||||
$subscription->team?->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to avoid Stripe rate limits
|
||||
usleep(100000); // 100ms
|
||||
usleep(100000); // 100ms rate limit delay
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this user resubscribed under a different customer/subscription
|
||||
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
|
||||
if ($activeSub) {
|
||||
$resubscribed[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
'team_id' => $subscription->team_id,
|
||||
'email' => $activeSub['email'],
|
||||
'old_stripe_subscription_id' => $subscription->stripe_subscription_id,
|
||||
'old_stripe_customer_id' => $stripeSubscription->customer,
|
||||
'new_stripe_subscription_id' => $activeSub['subscription_id'],
|
||||
'new_stripe_customer_id' => $activeSub['customer_id'],
|
||||
'new_status' => $activeSub['status'],
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$discrepancies[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
'team_id' => $subscription->team_id,
|
||||
'stripe_subscription_id' => $subscription->stripe_subscription_id,
|
||||
'stripe_status' => $stripeStatus,
|
||||
];
|
||||
|
||||
if ($this->fix) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
if ($stripeStatus === 'canceled') {
|
||||
$subscription->team?->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify if discrepancies found and fixed
|
||||
if ($this->fix && count($discrepancies) > 0) {
|
||||
send_internal_notification(
|
||||
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
|
||||
@@ -85,8 +110,88 @@ class SyncStripeSubscriptionsJob implements ShouldQueue
|
||||
return [
|
||||
'total_checked' => $subscriptions->count(),
|
||||
'discrepancies' => $discrepancies,
|
||||
'resubscribed' => $resubscribed,
|
||||
'errors' => $errors,
|
||||
'fixed' => $this->fix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a Stripe customer ID, get their email and search for other customers
|
||||
* with the same email that have an active subscription.
|
||||
*
|
||||
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
|
||||
*/
|
||||
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
|
||||
{
|
||||
try {
|
||||
$customer = $stripe->customers->retrieve($customerId);
|
||||
$email = $customer->email;
|
||||
|
||||
if (! $email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usleep(100000);
|
||||
|
||||
$customers = $stripe->customers->all([
|
||||
'email' => $email,
|
||||
'limit' => 10,
|
||||
]);
|
||||
|
||||
usleep(100000);
|
||||
|
||||
foreach ($customers->data as $matchingCustomer) {
|
||||
if ($matchingCustomer->id === $customerId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subs = $stripe->subscriptions->all([
|
||||
'customer' => $matchingCustomer->id,
|
||||
'limit' => 10,
|
||||
]);
|
||||
|
||||
usleep(100000);
|
||||
|
||||
foreach ($subs->data as $sub) {
|
||||
if (in_array($sub->status, ['active', 'past_due'])) {
|
||||
return [
|
||||
'email' => $email,
|
||||
'customer_id' => $matchingCustomer->id,
|
||||
'subscription_id' => $sub->id,
|
||||
'status' => $sub->status,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Silently skip — will fall through to normal discrepancy
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fetch all active and past_due subscription IDs from Stripe.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
|
||||
{
|
||||
$validIds = [];
|
||||
$fetched = 0;
|
||||
|
||||
foreach (['active', 'past_due'] as $status) {
|
||||
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
|
||||
$validIds[] = $sub->id;
|
||||
$fetched++;
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($fetched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $validIds;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,11 @@ class Heading extends Component
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->parameters = [
|
||||
'project_uuid' => $this->database->environment->project->uuid,
|
||||
'environment_uuid' => $this->database->environment->uuid,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
];
|
||||
}
|
||||
|
||||
public function stop()
|
||||
|
||||
@@ -63,10 +63,16 @@ class DockerCompose extends Component
|
||||
]);
|
||||
|
||||
$variables = parseEnvFormatToArray($this->envFile);
|
||||
foreach ($variables as $key => $variable) {
|
||||
foreach ($variables as $key => $data) {
|
||||
// Extract value and comment from parsed data
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $variable,
|
||||
'value' => $value,
|
||||
'comment' => $comment,
|
||||
'is_preview' => false,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
|
||||
@@ -31,6 +31,8 @@ class Add extends Component
|
||||
|
||||
public bool $is_buildtime = true;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public array $problematicVariables = [];
|
||||
|
||||
protected $listeners = ['clearAddEnv' => 'clear'];
|
||||
@@ -42,6 +44,7 @@ class Add extends Component
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
@@ -51,6 +54,7 @@ class Add extends Component
|
||||
'is_literal' => 'literal',
|
||||
'is_runtime' => 'runtime',
|
||||
'is_buildtime' => 'buildtime',
|
||||
'comment' => 'comment',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -136,6 +140,7 @@ class Add extends Component
|
||||
'is_runtime' => $this->is_runtime,
|
||||
'is_buildtime' => $this->is_buildtime,
|
||||
'is_preview' => $this->is_preview,
|
||||
'comment' => $this->comment,
|
||||
]);
|
||||
$this->clear();
|
||||
}
|
||||
@@ -148,5 +153,6 @@ class Add extends Component
|
||||
$this->is_literal = false;
|
||||
$this->is_runtime = true;
|
||||
$this->is_buildtime = true;
|
||||
$this->comment = null;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,62 @@ class All extends Component
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function getHardcodedEnvironmentVariablesProperty()
|
||||
{
|
||||
return $this->getHardcodedVariables(false);
|
||||
}
|
||||
|
||||
public function getHardcodedEnvironmentVariablesPreviewProperty()
|
||||
{
|
||||
return $this->getHardcodedVariables(true);
|
||||
}
|
||||
|
||||
protected function getHardcodedVariables(bool $isPreview)
|
||||
{
|
||||
// Only for services and docker-compose applications
|
||||
if ($this->resource->type() !== 'service' &&
|
||||
($this->resourceClass !== 'App\Models\Application' ||
|
||||
($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
$dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
|
||||
|
||||
if (blank($dockerComposeRaw)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
// Extract all hard-coded variables
|
||||
$hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw);
|
||||
|
||||
// Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*)
|
||||
$hardcodedVars = $hardcodedVars->filter(function ($var) {
|
||||
$key = $var['key'];
|
||||
|
||||
return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']);
|
||||
});
|
||||
|
||||
// Filter out variables that exist in database (user has overridden/managed them)
|
||||
// For preview, check against preview variables; for production, check against production variables
|
||||
if ($isPreview) {
|
||||
$managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray();
|
||||
} else {
|
||||
$managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray();
|
||||
}
|
||||
|
||||
$hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) {
|
||||
return ! in_array($var['key'], $managedKeys);
|
||||
});
|
||||
|
||||
// Apply sorting based on is_env_sorting_enabled
|
||||
if ($this->is_env_sorting_enabled) {
|
||||
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
|
||||
}
|
||||
// Otherwise keep order from docker-compose file
|
||||
|
||||
return $hardcodedVars;
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
|
||||
@@ -240,6 +296,7 @@ class All extends Component
|
||||
$environment->is_runtime = $data['is_runtime'] ?? true;
|
||||
$environment->is_buildtime = $data['is_buildtime'] ?? true;
|
||||
$environment->is_preview = $data['is_preview'] ?? false;
|
||||
$environment->comment = $data['comment'] ?? null;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
$environment->resourceable_type = $this->resource->getMorphClass();
|
||||
|
||||
@@ -280,18 +337,37 @@ class All extends Component
|
||||
private function updateOrCreateVariables($isPreview, $variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract value and comment from parsed data
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
|
||||
$found = $this->resource->$method()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
if (! $found->is_shown_once && ! $found->is_multiline) {
|
||||
// Only count as a change if the value actually changed
|
||||
$changed = false;
|
||||
|
||||
// Update value if it changed
|
||||
if ($found->value !== $value) {
|
||||
$found->value = $value;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// Only update comment from inline comment if one is provided (overwrites existing)
|
||||
// If $comment is null, don't touch existing comment field to preserve it
|
||||
if ($comment !== null && $found->comment !== $comment) {
|
||||
$found->comment = $comment;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$found->save();
|
||||
$count++;
|
||||
}
|
||||
@@ -300,6 +376,7 @@ class All extends Component
|
||||
$environment = new EnvironmentVariable;
|
||||
$environment->key = $key;
|
||||
$environment->value = $value;
|
||||
$environment->comment = $comment; // Set comment from inline comment
|
||||
$environment->is_multiline = false;
|
||||
$environment->is_preview = $isPreview;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
|
||||
@@ -24,6 +24,8 @@ class Show extends Component
|
||||
|
||||
public bool $isLocked = false;
|
||||
|
||||
public bool $isMagicVariable = false;
|
||||
|
||||
public bool $isSharedVariable = false;
|
||||
|
||||
public string $type;
|
||||
@@ -34,6 +36,8 @@ class Show extends Component
|
||||
|
||||
public ?string $real_value = null;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public bool $is_shared = false;
|
||||
|
||||
public bool $is_multiline = false;
|
||||
@@ -63,6 +67,7 @@ class Show extends Component
|
||||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
@@ -104,6 +109,7 @@ class Show extends Component
|
||||
$this->validate([
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
@@ -118,6 +124,7 @@ class Show extends Component
|
||||
}
|
||||
$this->env->key = $this->key;
|
||||
$this->env->value = $this->value;
|
||||
$this->env->comment = $this->comment;
|
||||
$this->env->is_multiline = $this->is_multiline;
|
||||
$this->env->is_literal = $this->is_literal;
|
||||
$this->env->is_shown_once = $this->is_shown_once;
|
||||
@@ -125,6 +132,7 @@ class Show extends Component
|
||||
} else {
|
||||
$this->key = $this->env->key;
|
||||
$this->value = $this->env->value;
|
||||
$this->comment = $this->env->comment;
|
||||
$this->is_multiline = $this->env->is_multiline;
|
||||
$this->is_literal = $this->env->is_literal;
|
||||
$this->is_shown_once = $this->env->is_shown_once;
|
||||
@@ -140,9 +148,13 @@ class Show extends Component
|
||||
public function checkEnvs()
|
||||
{
|
||||
$this->isDisabled = false;
|
||||
$this->isMagicVariable = false;
|
||||
|
||||
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;
|
||||
$this->isMagicVariable = true;
|
||||
}
|
||||
|
||||
if ($this->env->is_shown_once) {
|
||||
$this->isLocked = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowHardcoded extends Component
|
||||
{
|
||||
public array $env;
|
||||
|
||||
public string $key;
|
||||
|
||||
public ?string $value = null;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public ?string $serviceName = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->key = $this->env['key'];
|
||||
$this->value = $this->env['value'] ?? null;
|
||||
$this->comment = $this->env['comment'] ?? null;
|
||||
$this->serviceName = $this->env['service_name'] ?? null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.environment-variable.show-hardcoded');
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,25 @@ class HealthChecks extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $healthCheckEnabled = false;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['string', 'in:http,cmd'])]
|
||||
public string $healthCheckType = 'http';
|
||||
|
||||
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
|
||||
public ?string $healthCheckCommand = null;
|
||||
|
||||
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
|
||||
public string $healthCheckMethod;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'in:http,https'])]
|
||||
public string $healthCheckScheme;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
|
||||
public string $healthCheckHost;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
|
||||
public ?string $healthCheckPort = null;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
|
||||
public string $healthCheckPath;
|
||||
|
||||
#[Validate(['integer'])]
|
||||
@@ -54,12 +60,14 @@ class HealthChecks extends Component
|
||||
|
||||
protected $rules = [
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckPath' => 'string',
|
||||
'healthCheckPort' => 'nullable|string',
|
||||
'healthCheckHost' => 'string',
|
||||
'healthCheckMethod' => 'string',
|
||||
'healthCheckType' => 'string|in:http,cmd',
|
||||
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
|
||||
'healthCheckReturnCode' => 'integer',
|
||||
'healthCheckScheme' => 'string',
|
||||
'healthCheckScheme' => 'required|string|in:http,https',
|
||||
'healthCheckResponseText' => 'nullable|string',
|
||||
'healthCheckInterval' => 'integer|min:1',
|
||||
'healthCheckTimeout' => 'integer|min:1',
|
||||
@@ -81,6 +89,8 @@ class HealthChecks extends Component
|
||||
|
||||
// Sync to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
@@ -98,6 +108,8 @@ class HealthChecks extends Component
|
||||
} else {
|
||||
// Sync from model
|
||||
$this->healthCheckEnabled = $this->resource->health_check_enabled;
|
||||
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
|
||||
$this->healthCheckCommand = $this->resource->health_check_command;
|
||||
$this->healthCheckMethod = $this->resource->health_check_method;
|
||||
$this->healthCheckScheme = $this->resource->health_check_scheme;
|
||||
$this->healthCheckHost = $this->resource->health_check_host;
|
||||
@@ -116,9 +128,12 @@ class HealthChecks extends Component
|
||||
public function instantSave()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate();
|
||||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
@@ -143,6 +158,8 @@ class HealthChecks extends Component
|
||||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
@@ -171,6 +188,8 @@ class HealthChecks extends Component
|
||||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
||||
@@ -49,9 +49,10 @@ class ResourceOperations extends Component
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$new_destination = StandaloneDocker::find($destination_id);
|
||||
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
|
||||
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
if (! $new_destination) {
|
||||
$new_destination = SwarmDocker::find($destination_id);
|
||||
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
}
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
@@ -352,7 +353,7 @@ class ResourceOperations extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$new_environment = Environment::findOrFail($environment_id);
|
||||
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
|
||||
$this->resource->update([
|
||||
'environment_id' => $environment_id,
|
||||
]);
|
||||
|
||||
@@ -60,10 +60,16 @@ class Show extends Component
|
||||
throw new \Exception('Certificate content cannot be empty.');
|
||||
}
|
||||
|
||||
if (! openssl_x509_read($this->certificateContent)) {
|
||||
$parsedCert = openssl_x509_read($this->certificateContent);
|
||||
if (! $parsedCert) {
|
||||
throw new \Exception('Invalid certificate format.');
|
||||
}
|
||||
|
||||
if (! openssl_x509_export($parsedCert, $cleanedCertificate)) {
|
||||
throw new \Exception('Failed to process certificate.');
|
||||
}
|
||||
$this->certificateContent = $cleanedCertificate;
|
||||
|
||||
if ($this->caCertificate) {
|
||||
$this->caCertificate->ssl_certificate = $this->certificateContent;
|
||||
$this->caCertificate->save();
|
||||
@@ -114,12 +120,14 @@ class Show extends Component
|
||||
{
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($this->certificateContent);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\Server;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -34,6 +39,53 @@ class DockerCleanup extends Component
|
||||
#[Validate('boolean')]
|
||||
public bool $disableApplicationImageRetention = false;
|
||||
|
||||
#[Computed]
|
||||
public function isCleanupStale(): bool
|
||||
{
|
||||
try {
|
||||
$lastExecution = DockerCleanupExecution::where('server_id', $this->server->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
if (! $lastExecution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *';
|
||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
$cron = new CronExpression($frequency);
|
||||
$now = Carbon::now();
|
||||
$nextRun = Carbon::parse($cron->getNextRunDate($now));
|
||||
$afterThat = Carbon::parse($cron->getNextRunDate($nextRun));
|
||||
$intervalMinutes = $nextRun->diffInMinutes($afterThat);
|
||||
|
||||
$threshold = max($intervalMinutes * 2, 10);
|
||||
|
||||
return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function lastExecutionTime(): ?string
|
||||
{
|
||||
return DockerCleanupExecution::where('server_id', $this->server->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first()
|
||||
?->created_at
|
||||
?->diffForHumans();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isSchedulerHealthy(): bool
|
||||
{
|
||||
return Cache::get('scheduled-job-manager:heartbeat') !== null;
|
||||
}
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\New;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -55,8 +56,8 @@ class ByIp extends Component
|
||||
'new_private_key_value' => 'nullable|string',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'ip' => 'required|string',
|
||||
'user' => 'required|string',
|
||||
'ip' => ['required', 'string', new ValidServerIp],
|
||||
'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'port' => 'required|integer|between:1,65535',
|
||||
'is_build_server' => 'required|boolean',
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Server\StopSentinel;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Services\HetznerService;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
@@ -106,9 +107,9 @@ class Show extends Component
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'ip' => 'required',
|
||||
'user' => 'required',
|
||||
'port' => 'required',
|
||||
'ip' => ['required', new ValidServerIp],
|
||||
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'port' => 'required|integer|between:1,65535',
|
||||
'validationLogs' => 'nullable',
|
||||
'wildcardDomain' => 'nullable|url',
|
||||
'isReachable' => 'required',
|
||||
|
||||
@@ -95,7 +95,9 @@ class Advanced extends Component
|
||||
// Check if it's valid CIDR notation
|
||||
if (str_contains($entry, '/')) {
|
||||
[$ip, $mask] = explode('/', $entry);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) {
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$maxMask = $isIpv6 ? 128 : 32;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) {
|
||||
return $entry;
|
||||
}
|
||||
$invalidEntries[] = $entry;
|
||||
@@ -111,7 +113,7 @@ class Advanced extends Component
|
||||
$invalidEntries[] = $entry;
|
||||
|
||||
return null;
|
||||
})->filter()->unique();
|
||||
})->filter()->values()->all();
|
||||
|
||||
if (! empty($invalidEntries)) {
|
||||
$this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries));
|
||||
@@ -119,13 +121,15 @@ class Advanced extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if ($validEntries->isEmpty()) {
|
||||
if (empty($validEntries)) {
|
||||
$this->dispatch('error', 'No valid IP addresses or subnets provided');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->allowed_ips = $validEntries->implode(',');
|
||||
$validEntries = deduplicateAllowlist($validEntries);
|
||||
|
||||
$this->allowed_ips = implode(',', $validEntries);
|
||||
}
|
||||
|
||||
$this->instantSave();
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Services\SchedulerLogParser;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -16,6 +19,18 @@ class ScheduledJobs extends Component
|
||||
|
||||
public string $filterDate = 'last_24h';
|
||||
|
||||
public int $skipPage = 0;
|
||||
|
||||
public int $skipDefaultTake = 20;
|
||||
|
||||
public bool $showSkipNext = false;
|
||||
|
||||
public bool $showSkipPrev = false;
|
||||
|
||||
public int $skipCurrentPage = 1;
|
||||
|
||||
public int $skipTotalCount = 0;
|
||||
|
||||
protected Collection $executions;
|
||||
|
||||
protected Collection $skipLogs;
|
||||
@@ -42,11 +57,30 @@ class ScheduledJobs extends Component
|
||||
|
||||
public function updatedFilterType(): void
|
||||
{
|
||||
$this->skipPage = 0;
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedFilterDate(): void
|
||||
{
|
||||
$this->skipPage = 0;
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function skipNextPage(): void
|
||||
{
|
||||
$this->skipPage += $this->skipDefaultTake;
|
||||
$this->showSkipPrev = true;
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function skipPreviousPage(): void
|
||||
{
|
||||
$this->skipPage -= $this->skipDefaultTake;
|
||||
if ($this->skipPage < 0) {
|
||||
$this->skipPage = 0;
|
||||
}
|
||||
$this->showSkipPrev = $this->skipPage > 0;
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
@@ -69,10 +103,86 @@ class ScheduledJobs extends Component
|
||||
$this->executions = $this->getExecutions($teamId);
|
||||
|
||||
$parser = new SchedulerLogParser;
|
||||
$this->skipLogs = $parser->getRecentSkips(50, $teamId);
|
||||
$allSkips = $parser->getRecentSkips(500, $teamId);
|
||||
$this->skipTotalCount = $allSkips->count();
|
||||
$this->skipLogs = $this->enrichSkipLogsWithLinks(
|
||||
$allSkips->slice($this->skipPage, $this->skipDefaultTake)->values()
|
||||
);
|
||||
$this->showSkipPrev = $this->skipPage > 0;
|
||||
$this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount;
|
||||
$this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1;
|
||||
$this->managerRuns = $parser->getRecentRuns(30, $teamId);
|
||||
}
|
||||
|
||||
private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection
|
||||
{
|
||||
$taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values();
|
||||
$backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values();
|
||||
$serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values();
|
||||
|
||||
$tasks = $taskIds->isNotEmpty()
|
||||
? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id')
|
||||
: collect();
|
||||
|
||||
$backups = $backupIds->isNotEmpty()
|
||||
? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id')
|
||||
: collect();
|
||||
|
||||
$servers = $serverIds->isNotEmpty()
|
||||
? Server::whereIn('id', $serverIds)->get()->keyBy('id')
|
||||
: collect();
|
||||
|
||||
return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array {
|
||||
$skip['link'] = null;
|
||||
$skip['resource_name'] = null;
|
||||
|
||||
if ($skip['type'] === 'task') {
|
||||
$task = $tasks->get($skip['context']['task_id'] ?? null);
|
||||
if ($task) {
|
||||
$skip['resource_name'] = $skip['context']['task_name'] ?? $task->name;
|
||||
$resource = $task->application ?? $task->service;
|
||||
$environment = $resource?->environment;
|
||||
$project = $environment?->project;
|
||||
if ($project && $environment && $resource) {
|
||||
$routeName = $task->application_id
|
||||
? 'project.application.scheduled-tasks'
|
||||
: 'project.service.scheduled-tasks';
|
||||
$routeKey = $task->application_id ? 'application_uuid' : 'service_uuid';
|
||||
$skip['link'] = route($routeName, [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
$routeKey => $resource->uuid,
|
||||
'task_uuid' => $task->uuid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif ($skip['type'] === 'backup') {
|
||||
$backup = $backups->get($skip['context']['backup_id'] ?? null);
|
||||
if ($backup) {
|
||||
$database = $backup->database;
|
||||
$skip['resource_name'] = $database?->name ?? 'Database backup';
|
||||
$environment = $database?->environment;
|
||||
$project = $environment?->project;
|
||||
if ($project && $environment && $database) {
|
||||
$skip['link'] = route('project.database.backup.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif ($skip['type'] === 'docker_cleanup') {
|
||||
$server = $servers->get($skip['context']['server_id'] ?? null);
|
||||
if ($server) {
|
||||
$skip['resource_name'] = $server->name;
|
||||
$skip['link'] = route('server.show', ['server_uuid' => $server->uuid]);
|
||||
}
|
||||
}
|
||||
|
||||
return $skip;
|
||||
});
|
||||
}
|
||||
|
||||
private function getExecutions(?int $teamId = null): Collection
|
||||
{
|
||||
$dateFrom = $this->getDateFrom();
|
||||
|
||||
@@ -40,6 +40,7 @@ class Show extends Component
|
||||
'value' => $data['value'],
|
||||
'is_multiline' => $data['is_multiline'],
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'environment',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,7 @@ class Show extends Component
|
||||
'value' => $data['value'],
|
||||
'is_multiline' => $data['is_multiline'],
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'project',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,7 @@ class Index extends Component
|
||||
'value' => $data['value'],
|
||||
'is_multiline' => $data['is_multiline'],
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'team',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
@@ -2,21 +2,204 @@
|
||||
|
||||
namespace App\Livewire\Subscription;
|
||||
|
||||
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
||||
use App\Actions\Stripe\RefundSubscription;
|
||||
use App\Actions\Stripe\ResumeSubscription;
|
||||
use App\Actions\Stripe\UpdateSubscriptionQuantity;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class Actions extends Component
|
||||
{
|
||||
public $server_limits = 0;
|
||||
|
||||
public function mount()
|
||||
public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||
|
||||
public int $minServerLimit = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||
|
||||
public int $maxServerLimit = UpdateSubscriptionQuantity::MAX_SERVER_LIMIT;
|
||||
|
||||
public ?array $pricePreview = null;
|
||||
|
||||
public bool $isRefundEligible = false;
|
||||
|
||||
public int $refundDaysRemaining = 0;
|
||||
|
||||
public bool $refundCheckLoading = true;
|
||||
|
||||
public bool $refundAlreadyUsed = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->server_limits = Team::serverLimit();
|
||||
$this->quantity = (int) $this->server_limits;
|
||||
}
|
||||
|
||||
public function stripeCustomerPortal()
|
||||
public function loadPricePreview(int $quantity): void
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
$result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $quantity);
|
||||
$this->pricePreview = $result['success'] ? $result['preview'] : null;
|
||||
}
|
||||
|
||||
// Password validation is intentionally skipped for quantity updates.
|
||||
// Unlike refunds/cancellations, changing the server limit is a
|
||||
// non-destructive, reversible billing adjustment (prorated by Stripe).
|
||||
public function updateQuantity(string $password = ''): bool
|
||||
{
|
||||
if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) {
|
||||
$this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.');
|
||||
$this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->quantity === (int) $this->server_limits) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->server_limits = $this->quantity;
|
||||
$this->pricePreview = null;
|
||||
$this->dispatch('success', 'Server limit updated to '.$this->quantity.'.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', $result['error'] ?? 'Failed to update server limit.');
|
||||
$this->quantity = (int) $this->server_limits;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function loadRefundEligibility(): void
|
||||
{
|
||||
$this->checkRefundEligibility();
|
||||
$this->refundCheckLoading = false;
|
||||
}
|
||||
|
||||
public function stripeCustomerPortal(): void
|
||||
{
|
||||
$session = getStripeCustomerPortalSession(currentTeam());
|
||||
redirect($session->url);
|
||||
}
|
||||
|
||||
public function refundSubscription(string $password): bool|string
|
||||
{
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$result = (new RefundSubscription)->execute(currentTeam());
|
||||
|
||||
if ($result['success']) {
|
||||
$this->dispatch('success', 'Subscription refunded successfully.');
|
||||
$this->redirect(route('subscription.index'), navigate: true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Something went wrong with the refund. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancelImmediately(string $password): bool|string
|
||||
{
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$team = currentTeam();
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'Cancelled immediately by user',
|
||||
'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} cancelled immediately for team {$team->name}");
|
||||
|
||||
$this->dispatch('success', 'Subscription cancelled successfully.');
|
||||
$this->redirect(route('subscription.index'), navigate: true);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelAtPeriodEnd(string $password): bool|string
|
||||
{
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$result = (new CancelSubscriptionAtPeriodEnd)->execute(currentTeam());
|
||||
|
||||
if ($result['success']) {
|
||||
$this->dispatch('success', 'Subscription will be cancelled at the end of the billing period.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resumeSubscription(): bool
|
||||
{
|
||||
$result = (new ResumeSubscription)->execute(currentTeam());
|
||||
|
||||
if ($result['success']) {
|
||||
$this->dispatch('success', 'Subscription resumed successfully.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Something went wrong resuming the subscription. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkRefundEligibility(): void
|
||||
{
|
||||
if (! isCloud() || ! currentTeam()->subscription?->stripe_subscription_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->refundAlreadyUsed = currentTeam()->subscription?->stripe_refunded_at !== null;
|
||||
$result = (new RefundSubscription)->checkEligibility(currentTeam());
|
||||
$this->isRefundEligible = $result['eligible'];
|
||||
$this->refundDaysRemaining = $result['days_remaining'];
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-19
@@ -61,6 +61,8 @@ use Visus\Cuid2\Cuid2;
|
||||
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
|
||||
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
|
||||
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
|
||||
'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
|
||||
'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
|
||||
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
|
||||
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
|
||||
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
|
||||
@@ -1085,19 +1087,24 @@ class Application extends BaseModel
|
||||
return application_configuration_dir()."/{$this->uuid}";
|
||||
}
|
||||
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
|
||||
if ($this->git_commit_sha !== 'HEAD') {
|
||||
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
|
||||
// Invalid refs will cause the git checkout/fetch command to fail on the remote server.
|
||||
$commitToUse = $commit ?? $this->git_commit_sha;
|
||||
|
||||
if ($commitToUse !== 'HEAD') {
|
||||
$escapedCommit = escapeshellarg($commitToUse);
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
@@ -1285,7 +1292,7 @@ class Application extends BaseModel
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
@@ -1306,7 +1313,7 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = $repoUrl;
|
||||
}
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
@@ -1345,7 +1352,7 @@ class Application extends BaseModel
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
@@ -1403,7 +1410,7 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
if ($git_type === 'gitlab') {
|
||||
@@ -1959,17 +1966,6 @@ class Application extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDomainsByUuid(string $uuid): array
|
||||
{
|
||||
$application = self::where('uuid', $uuid)->first();
|
||||
|
||||
if ($application) {
|
||||
return $application->fqdns;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getLimits(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
@@ -21,6 +22,7 @@ use OpenApi\Attributes as OA;
|
||||
class Environment extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache;
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -24,6 +24,7 @@ use OpenApi\Attributes as OA;
|
||||
'key' => ['type' => 'string'],
|
||||
'value' => ['type' => 'string'],
|
||||
'real_value' => ['type' => 'string'],
|
||||
'comment' => ['type' => 'string', 'nullable' => true],
|
||||
'version' => ['type' => 'string'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
@@ -31,7 +32,30 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class EnvironmentVariable extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
// Core identification
|
||||
'key',
|
||||
'value',
|
||||
'comment',
|
||||
|
||||
// Polymorphic relationship
|
||||
'resourceable_type',
|
||||
'resourceable_id',
|
||||
|
||||
// Boolean flags
|
||||
'is_preview',
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_runtime',
|
||||
'is_buildtime',
|
||||
'is_shown_once',
|
||||
'is_shared',
|
||||
'is_required',
|
||||
|
||||
// Metadata
|
||||
'version',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'key' => 'string',
|
||||
@@ -67,6 +91,7 @@ class EnvironmentVariable extends BaseModel
|
||||
'is_literal' => $environment_variable->is_literal ?? false,
|
||||
'is_runtime' => $environment_variable->is_runtime ?? false,
|
||||
'is_buildtime' => $environment_variable->is_buildtime ?? false,
|
||||
'comment' => $environment_variable->comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $environment_variable->resourceable_id,
|
||||
'is_preview' => true,
|
||||
|
||||
@@ -237,7 +237,7 @@ class PrivateKey extends BaseModel
|
||||
$testSuccess = $disk->put($testFilename, 'test');
|
||||
|
||||
if (! $testSuccess) {
|
||||
throw new \Exception('SSH keys storage directory is not writable');
|
||||
throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -20,6 +21,7 @@ use Visus\Cuid2\Cuid2;
|
||||
class Project extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache;
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -25,6 +26,7 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class ScheduledTask extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
+12
-1
@@ -913,6 +913,9 @@ $schema://$host {
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
return (int) preg_replace('/[^0-9]/', '', $value);
|
||||
},
|
||||
set: function ($value) {
|
||||
return (int) preg_replace('/[^0-9]/', '', (string) $value);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -922,6 +925,9 @@ $schema://$host {
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
|
||||
},
|
||||
set: function ($value) {
|
||||
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -931,6 +937,9 @@ $schema://$host {
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value);
|
||||
},
|
||||
set: function ($value) {
|
||||
return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1452,12 +1461,14 @@ $schema://$host {
|
||||
$certificateContent = $caCertificate->ssl_certificate;
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($certificateContent);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$certificateContent}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,7 +6,23 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SharedEnvironmentVariable extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
// Core identification
|
||||
'key',
|
||||
'value',
|
||||
'comment',
|
||||
|
||||
// Type and relationships
|
||||
'type',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
|
||||
// Boolean flags
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_shown_once',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'key' => 'string',
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Models;
|
||||
|
||||
use App\Jobs\ConnectProxyToNetworksJob;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class StandaloneDocker extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -8,11 +8,32 @@ class Subscription extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'stripe_refunded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function billingInterval(): string
|
||||
{
|
||||
if ($this->stripe_plan_id) {
|
||||
$configKey = collect(config('subscription'))
|
||||
->search($this->stripe_plan_id);
|
||||
|
||||
if ($configKey && str($configKey)->contains('yearly')) {
|
||||
return 'yearly';
|
||||
}
|
||||
}
|
||||
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
if (isStripe()) {
|
||||
|
||||
@@ -37,8 +37,7 @@ class StandaloneDockerPolicy
|
||||
*/
|
||||
public function update(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,8 +45,7 @@ class StandaloneDockerPolicy
|
||||
*/
|
||||
public function delete(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +53,7 @@ class StandaloneDockerPolicy
|
||||
*/
|
||||
public function restore(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +61,6 @@ class StandaloneDockerPolicy
|
||||
*/
|
||||
public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,7 @@ class SwarmDockerPolicy
|
||||
*/
|
||||
public function update(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,8 +45,7 @@ class SwarmDockerPolicy
|
||||
*/
|
||||
public function delete(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +53,7 @@ class SwarmDockerPolicy
|
||||
*/
|
||||
public function restore(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +61,6 @@ class SwarmDockerPolicy
|
||||
*/
|
||||
public function forceDelete(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ class ValidIpOrCidr implements ValidationRule
|
||||
|
||||
[$ip, $mask] = $parts;
|
||||
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) {
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$maxMask = $isIpv6 ? 128 : 32;
|
||||
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) {
|
||||
$invalidEntries[] = $entry;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ValidServerIp implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Accepts a valid IPv4 address, IPv6 address, or RFC 1123 hostname.
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
if (filter_var($trimmed, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter_var($trimmed, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate hostname validation to ValidHostname
|
||||
$hostnameRule = new ValidHostname;
|
||||
$failed = false;
|
||||
$hostnameRule->validate($attribute, $trimmed, function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
|
||||
if ($failed) {
|
||||
$fail('The :attribute must be a valid IPv4 address, IPv6 address, or hostname.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class SchedulerLogParser
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_contains($entry['message'], 'ScheduledJobManager')) {
|
||||
if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user