[v5.x] feat(cloud): v5 upgrade maintenance notification (#9875)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-05-11 17:36:57 +02:00
commit 46c0fdb063
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>