mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
[v5.x] feat(cloud): v5 upgrade maintenance notification (#9875)
This commit is contained in:
3 files changed
+347
No files matched your search
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\TransactionalEmails\MaintenanceNotice;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendMaintenanceEmail extends Command
|
||||
{
|
||||
protected $signature = 'cloud:send-maintenance-email
|
||||
{--maintenance-at= : Required. Maintenance window start in Central European time (Europe/Budapest), e.g. "2026-04-29 22:00".}
|
||||
{--throttle-per-minute=1000 : Maximum emails dispatched per minute. Used to stay under rate limits.}
|
||||
{--test-to= : Send three test emails (active, inactive, active+inactive-team variants) to this address. Bypasses confirmations.}
|
||||
{--dry-run : Only report what would happen, do not send anything}';
|
||||
|
||||
protected $description = 'Send the v5 maintenance notification email to all Cloud users. Run 30 days, 7 days, and ~5h before the maintenance window.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isDev() && ! isCloud()) {
|
||||
$this->error('This command can only be run on Coolify Cloud.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$maintenanceAtInput = $this->option('maintenance-at');
|
||||
if (! $maintenanceAtInput) {
|
||||
$this->error('--maintenance-at is required, e.g. --maintenance-at="2026-04-29 22:00".');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$maintenanceAt = Carbon::parse($maintenanceAtInput, 'Europe/Budapest')->setTimezone('UTC');
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Could not parse --maintenance-at: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($testTo = $this->option('test-to')) {
|
||||
return $this->sendTestEmails($testTo, $maintenanceAt);
|
||||
}
|
||||
|
||||
$throttlePerMinute = (int) $this->option('throttle-per-minute');
|
||||
if ($throttlePerMinute < 1) {
|
||||
$this->error('--throttle-per-minute must be at least 1.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$totalUsers = User::query()->where('id', '!=', 0)->whereNotNull('email')->count();
|
||||
$estimatedMinutes = (int) ceil($totalUsers / $throttlePerMinute);
|
||||
|
||||
$this->info('Coolify Cloud — v5 maintenance notification');
|
||||
$this->line(' Maintenance window : '.$maintenanceAt->copy()->setTimezone('Europe/Budapest')->format('Y-m-d H:i').' CET/CEST ('.$maintenanceAt->format('Y-m-d H:i').' UTC)');
|
||||
$this->line(' Recipients : '.$totalUsers);
|
||||
$this->line(' Throttle : '.$throttlePerMinute.'/min (~'.$estimatedMinutes.' min to drain queue)');
|
||||
if ($dryRun) {
|
||||
$this->warn(' Dry run : YES (no emails will be sent)');
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Have you run `cloud:mark-inactive` recently? Inactive flags must be up to date before sending.', false)) {
|
||||
$this->warn('Aborted. Run `php artisan cloud:mark-inactive` first, then re-run this command.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! $this->confirm('Send maintenance notification to all '.$totalUsers.' users now?', false)) {
|
||||
$this->warn('Aborted.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$stats = ['active_sent' => 0, 'inactive_sent' => 0, 'failed' => 0, 'skipped' => 0];
|
||||
$dispatched = 0;
|
||||
$startedAt = now();
|
||||
|
||||
User::query()
|
||||
->select(['id', 'email', 'is_inactive'])
|
||||
->where('id', '!=', 0)
|
||||
->whereNotNull('email')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($users) use ($maintenanceAt, $dryRun, $startedAt, $throttlePerMinute, &$stats, &$dispatched) {
|
||||
foreach ($users as $user) {
|
||||
if (! filter_var($user->email, FILTER_VALIDATE_EMAIL)) {
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$isInactive = (bool) $user->is_inactive;
|
||||
$line = sprintf(' [%s] %s', $isInactive ? 'INACTIVE' : 'ACTIVE ', $user->email);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line($line.' (dry-run)');
|
||||
$isInactive ? $stats['inactive_sent']++ : $stats['active_sent']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$delayUntil = $startedAt->copy()->addSeconds(intdiv($dispatched * 60, $throttlePerMinute));
|
||||
$user->notify((new MaintenanceNotice($user, $maintenanceAt))->delay($delayUntil));
|
||||
$isInactive ? $stats['inactive_sent']++ : $stats['active_sent']++;
|
||||
$dispatched++;
|
||||
$this->line($line.' (delayed until '.$delayUntil->format('H:i:s').' UTC)');
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
$this->warn($line.' FAILED: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
collect($stats)->map(fn ($v, $k) => [$k, $v])->values()->all(),
|
||||
);
|
||||
|
||||
if (! $dryRun) {
|
||||
$finishesAt = $startedAt->copy()->addSeconds(intdiv($dispatched * 60, $throttlePerMinute));
|
||||
$this->newLine();
|
||||
$this->info('All '.$dispatched.' notifications queued. Last email scheduled to send around '.$finishesAt->format('Y-m-d H:i:s').' UTC.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function sendTestEmails(string $email, Carbon $maintenanceAt): int
|
||||
{
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address: '.$email);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('Sending TEST maintenance emails (active, inactive, active+inactive-team variants)');
|
||||
$this->line(' To : '.$email);
|
||||
$this->line(' Maintenance window : '.$maintenanceAt->copy()->setTimezone('Europe/Budapest')->format('Y-m-d H:i').' CET/CEST ('.$maintenanceAt->format('Y-m-d H:i').' UTC)');
|
||||
$this->newLine();
|
||||
|
||||
$variants = [
|
||||
['label' => 'ACTIVE ', 'inactive' => false, 'hasInactiveTeams' => false],
|
||||
['label' => 'INACTIVE ', 'inactive' => true, 'hasInactiveTeams' => false],
|
||||
['label' => 'ACTIVE + INACTIVE TEAM ', 'inactive' => false, 'hasInactiveTeams' => true],
|
||||
];
|
||||
|
||||
$exitCode = 0;
|
||||
foreach ($variants as $variant) {
|
||||
$user = new User;
|
||||
$user->name = 'Test Recipient';
|
||||
$user->email = $email;
|
||||
$user->is_inactive = $variant['inactive'];
|
||||
|
||||
try {
|
||||
$user->notifyNow(new MaintenanceNotice($user, $maintenanceAt, hasInactiveTeamsOverride: $variant['hasInactiveTeams']));
|
||||
$this->line(' ['.$variant['label'].'] sent');
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(' ['.$variant['label'].'] FAILED: '.$e->getMessage());
|
||||
$exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\TransactionalEmails;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\Channels\TransactionalEmailChannel;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class MaintenanceNotice extends CustomEmailNotification
|
||||
{
|
||||
public bool $isTransactionalEmail = true;
|
||||
|
||||
/**
|
||||
* @param Carbon $maintenanceAt Maintenance window start (UTC).
|
||||
* @param int $durationMinutes Planned duration of the maintenance window.
|
||||
* @param bool|null $hasInactiveTeamsOverride When set, bypasses the DB check (used for test emails).
|
||||
*/
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public Carbon $maintenanceAt,
|
||||
public int $durationMinutes = 240,
|
||||
public ?bool $hasInactiveTeamsOverride = null,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function via(): array
|
||||
{
|
||||
return [TransactionalEmailChannel::class];
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$startUtc = $this->maintenanceAt->copy()->utc();
|
||||
$endUtc = $startUtc->copy()->addMinutes($this->durationMinutes);
|
||||
$startEu = $startUtc->copy()->setTimezone('Europe/Budapest');
|
||||
$endEu = $endUtc->copy()->setTimezone('Europe/Budapest');
|
||||
$daysFromNow = (int) round(now()->diffInDays($startUtc, false));
|
||||
|
||||
$hasInactiveTeams = $this->hasInactiveTeamsOverride
|
||||
?? (! $this->user->is_inactive
|
||||
&& $this->user->teams()->where('teams.is_inactive', true)->exists());
|
||||
|
||||
$mail = new MailMessage;
|
||||
$mail->subject('Scheduled Maintenance: Coolify Cloud Infrastructure');
|
||||
$mail->view('emails.maintenance-notice', [
|
||||
'isInactive' => (bool) $this->user->is_inactive,
|
||||
'hasInactiveTeams' => $hasInactiveTeams,
|
||||
'daysFromNow' => $daysFromNow,
|
||||
'startUtcLong' => $startUtc->format('F j, Y H:i').' UTC',
|
||||
'endUtcLong' => $endUtc->format('F j, Y H:i').' UTC',
|
||||
'startEu' => $startEu->format('H:i'),
|
||||
'endEu' => $endEu->format('H:i'),
|
||||
'euAbbr' => $startEu->format('T'),
|
||||
'durationMinutes' => $this->durationMinutes,
|
||||
'subscriptionUrl' => 'https://app.coolify.io/subscription',
|
||||
'subscriptionNewUrl' => 'https://app.coolify.io/subscription/new',
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f4f4f5;padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" border="0" style="max-width:640px;width:100%;background-color:#ffffff;border:1px solid #e4e4e7;border-radius:12px;">
|
||||
<tr>
|
||||
<td style="padding:40px 40px 8px 40px;">
|
||||
<p style="margin:0 0 8px 0;font-size:16px;line-height:1.6;color:#0f172a;font-weight:700;">Dear Coolify Cloud Customer,</p>
|
||||
|
||||
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#0f172a;">
|
||||
@if ($daysFromNow <= 0)
|
||||
<strong>Today</strong> we will perform a scheduled maintenance to migrate Coolify Cloud from <strong>v4 to v5</strong>. The maintenance window:
|
||||
@elseif ($daysFromNow === 1)
|
||||
<strong>Tomorrow</strong> we will perform a scheduled maintenance to migrate Coolify Cloud from <strong>v4 to v5</strong>. The maintenance window:
|
||||
@else
|
||||
In <strong>{{ $daysFromNow }} days</strong> we will perform a scheduled maintenance to migrate Coolify Cloud from <strong>v4 to v5</strong>. The maintenance window:
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:0 0 16px 0;border:1px solid #e2e8f0;border-radius:8px;background-color:#f8fafc;">
|
||||
<tr>
|
||||
<td width="50%" style="padding:16px 20px;border-right:1px solid #e2e8f0;vertical-align:top;">
|
||||
<div style="font-size:12px;line-height:1.4;color:#64748b;font-weight:700;margin-bottom:6px;">START TIME</div>
|
||||
<div style="font-size:15px;line-height:1.4;color:#0f172a;font-weight:700;">{{ $startUtcLong }}</div>
|
||||
</td>
|
||||
<td width="50%" style="padding:16px 20px;vertical-align:top;">
|
||||
<div style="font-size:12px;line-height:1.4;color:#64748b;font-weight:700;margin-bottom:6px;">END TIME</div>
|
||||
<div style="font-size:15px;line-height:1.4;color:#0f172a;font-weight:700;">{{ $endUtcLong }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding:12px 20px;border-top:1px solid #e2e8f0;font-size:13px;line-height:1.5;color:#64748b;">
|
||||
Central European time: <strong style="color:#0f172a;">{{ $startEu }} to {{ $endEu }} {{ $euAbbr }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:1.6;color:#0f172a;">
|
||||
During the {{ $durationMinutes }}-minute window (it may complete sooner), the Coolify Cloud dashboard at <a href="https://app.coolify.io" style="color:#6b16ed;text-decoration:underline;">app.coolify.io</a> and the API at <a href="https://app.coolify.io/api" style="color:#6b16ed;text-decoration:underline;">app.coolify.io/api</a> will be offline and inaccessible.
|
||||
</p>
|
||||
|
||||
<h2 style="margin:32px 0 12px 0;font-size:17px;line-height:1.4;font-weight:700;color:#0f172a;">How will this affect my service?</h2>
|
||||
@if ($isInactive)
|
||||
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#0f172a;">Your account is inactive (no active subscription for 90+ days). <strong>Unless you resubscribe before the maintenance window, your account will not be migrated to v5 and your data will be permanently deleted.</strong></p>
|
||||
@else
|
||||
<ul style="margin:0 0 24px 0;padding-left:20px;font-size:15px;line-height:1.7;color:#0f172a;">
|
||||
@if ($hasInactiveTeams)
|
||||
<li>One or more of your teams are inactive (no active subscription for 90+ days). <strong>Inactive teams will not be migrated to v5 and their data will be permanently deleted.</strong></li>
|
||||
@endif
|
||||
<li>All your existing deployments, resources and servers will keep running as normal, <strong>so your public websites and services will continue to be fully accessible.</strong></li>
|
||||
<li>No deployments can be triggered during the maintenance window (this includes auto-deployments on commits).</li>
|
||||
<li>No configuration changes can be made during the maintenance window.</li>
|
||||
<li>You will be logged out and need to re-authenticate on your next visit.</li>
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<h2 style="margin:32px 0 12px 0;font-size:17px;line-height:1.4;font-weight:700;color:#0f172a;">Are there any required actions I need to take?</h2>
|
||||
|
||||
@if ($isInactive)
|
||||
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#0f172a;"><strong>Yes.</strong> Resubscribe before the maintenance window to keep your account: <a href="{{ $subscriptionNewUrl }}" style="color:#6b16ed;text-decoration:underline;">resubscribe here</a>.</p>
|
||||
@else
|
||||
@if ($hasInactiveTeams)
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:1.6;color:#0f172a;">Before the maintenance window: <strong>Yes.</strong></p>
|
||||
<ul style="margin:0 0 16px 0;padding-left:20px;font-size:15px;line-height:1.7;color:#0f172a;">
|
||||
<li>To keep your inactive team(s) and their data, resubscribe before the maintenance window: <a href="{{ $subscriptionUrl }}" style="color:#6b16ed;text-decoration:underline;">manage your subscription</a>.</li>
|
||||
<li>A few recommended preparations are also listed in the <a href="#" style="color:#6b16ed;text-decoration:underline;">v5 preparation guide</a>.</li>
|
||||
</ul>
|
||||
@else
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:1.6;color:#0f172a;">Before the maintenance window: <strong>No.</strong></p>
|
||||
<ul style="margin:0 0 16px 0;padding-left:20px;font-size:15px;line-height:1.7;color:#0f172a;">
|
||||
<li>A few recommended preparations are listed in the <a href="#" style="color:#6b16ed;text-decoration:underline;">v5 preparation guide</a>.</li>
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:1.6;color:#0f172a;">After the maintenance window: <strong>Yes</strong> (~10 minutes of work for most users):</p>
|
||||
<ol style="margin:0 0 8px 0;padding-left:20px;font-size:15px;line-height:1.7;color:#0f172a;">
|
||||
<li><a href="https://app.coolify.io/login" style="color:#6b16ed;text-decoration:underline;">Login</a> again.</li>
|
||||
<li>Upgrade all your servers from each server's detail page.</li>
|
||||
<li>Verify each application and database is correctly configured.</li>
|
||||
<li>Redeploy each application and database once to move them to the new deployment architecture. Auto-deployments on commits stay disabled until this step is completed.</li>
|
||||
</ol>
|
||||
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#0f172a;">Full details in the <a href="#" style="color:#6b16ed;text-decoration:underline;font-weight:700;">v5 upgrade guide</a>.</p>
|
||||
@endif
|
||||
|
||||
<h2 style="margin:32px 0 12px 0;font-size:17px;line-height:1.4;font-weight:700;color:#0f172a;">Can I reschedule or avoid this maintenance window?</h2>
|
||||
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#0f172a;">
|
||||
No. This is a required migration to a more reliable, modern and improved Coolify architecture and applies to all Cloud customers.
|
||||
</p>
|
||||
|
||||
<h2 style="margin:32px 0 12px 0;font-size:17px;line-height:1.4;font-weight:700;color:#0f172a;">Questions?</h2>
|
||||
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#0f172a;">
|
||||
Reach out to us in the <a href="#" style="color:#6b16ed;text-decoration:underline;font-weight:700;">migration</a> channel on the <a href="#" style="color:#6b16ed;text-decoration:underline;">coolLabs Discord</a> or simply reply to this email.
|
||||
</p>
|
||||
|
||||
<p style="margin:32px 0 0 0;font-size:15px;line-height:1.6;color:#0f172a;">Thanks,<br>The Coolify Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 40px 32px 40px;border-top:1px solid #f1f5f9;">
|
||||
<p style="margin:0;font-size:12px;line-height:1.5;color:#64748b;">You're receiving this email because you have a Coolify Cloud account at <a href="https://coolify.io" style="color:#64748b;text-decoration:underline;">coolify.io</a>.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user