feat: add ClickHouse backups and cloud ops tools
Enable scheduled ClickHouse backups across the job, API, and UI, and guard unsupported database types via isBackupSolutionAvailable(). Convert Stripe subscription sync from a job to an action with clearer discrepancy resolution, and add cloud:export-users plus cloud:cleanup-unverified-users with tests.
This commit is contained in:
parent
908b5cc09d
commit
2719d66042
23 changed files with 1198 additions and 71 deletions
|
|
@ -1,30 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Subscription;
|
||||
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 Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
class SyncStripeSubscriptions
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use AsAction;
|
||||
|
||||
public int $tries = 1;
|
||||
private const VALID_STRIPE_STATUSES = ['active', 'past_due'];
|
||||
|
||||
public int $timeout = 1800; // 30 minutes max
|
||||
|
||||
public function __construct(public bool $fix = false)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(?\Closure $onProgress = null): array
|
||||
public function handle(bool $fix = false, ?\Closure $onProgress = null): array
|
||||
{
|
||||
if (! isCloud() || ! isStripe()) {
|
||||
return ['error' => 'Not running on Cloud or Stripe not configured'];
|
||||
|
|
@ -34,7 +22,9 @@ public function handle(?\Closure $onProgress = null): array
|
|||
->where('stripe_invoice_paid', true)
|
||||
->get();
|
||||
|
||||
$stripe = app(StripeClient::class);
|
||||
$stripe = app()->bound(StripeClient::class)
|
||||
? app(StripeClient::class)
|
||||
: new StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
|
||||
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
|
||||
|
|
@ -43,13 +33,20 @@ public function handle(?\Closure $onProgress = null): array
|
|||
$staleSubscriptions = $subscriptions->filter(
|
||||
fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
|
||||
);
|
||||
$staleSubscriptionCount = $staleSubscriptions->count();
|
||||
|
||||
$onProgress?->__invoke('checking', 0, $staleSubscriptionCount);
|
||||
|
||||
// For each stale subscription, get the exact Stripe status and check for resubscriptions
|
||||
$discrepancies = [];
|
||||
$resubscribed = [];
|
||||
$errors = [];
|
||||
$fixedCount = 0;
|
||||
$manualReviewCount = 0;
|
||||
|
||||
foreach ($staleSubscriptions->values() as $index => $subscription) {
|
||||
$onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount);
|
||||
|
||||
foreach ($staleSubscriptions as $subscription) {
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
|
|
@ -66,8 +63,18 @@ public function handle(?\Closure $onProgress = null): array
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check if this user resubscribed under a different customer/subscription
|
||||
if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
|
||||
$validReplacement = Subscription::query()
|
||||
->where('team_id', $subscription->team_id)
|
||||
->where('id', '!=', $subscription->id)
|
||||
->where('stripe_invoice_paid', true)
|
||||
->whereIn('stripe_subscription_id', $validStripeIds)
|
||||
->first();
|
||||
|
||||
if ($activeSub) {
|
||||
$resubscribed[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
|
|
@ -78,33 +85,69 @@ public function handle(?\Closure $onProgress = null): array
|
|||
'new_stripe_subscription_id' => $activeSub['subscription_id'],
|
||||
'new_stripe_customer_id' => $activeSub['customer_id'],
|
||||
'new_status' => $activeSub['status'],
|
||||
'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'],
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$inactiveSubscription = null;
|
||||
if (! $validReplacement && ! $activeSub) {
|
||||
$inactiveSubscription = Subscription::query()
|
||||
->where('team_id', $subscription->team_id)
|
||||
->where('id', '!=', $subscription->id)
|
||||
->where('stripe_invoice_paid', false)
|
||||
->first();
|
||||
}
|
||||
|
||||
$resolution = match (true) {
|
||||
(bool) $validReplacement => 'delete_stale',
|
||||
(bool) $activeSub => 'manual_review',
|
||||
(bool) $inactiveSubscription => 'delete_stale',
|
||||
default => 'end_subscription',
|
||||
};
|
||||
|
||||
$discrepancies[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
'team_id' => $subscription->team_id,
|
||||
'stripe_subscription_id' => $subscription->stripe_subscription_id,
|
||||
'stripe_status' => $stripeStatus,
|
||||
'resolution' => $resolution,
|
||||
];
|
||||
|
||||
if ($this->fix) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
if ($fix) {
|
||||
$team = $subscription->team;
|
||||
|
||||
if ($stripeStatus === 'canceled') {
|
||||
$subscription->team?->subscriptionEnded();
|
||||
if ($resolution === 'manual_review') {
|
||||
$manualReviewCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($resolution === 'delete_stale') {
|
||||
if (! $validReplacement && $inactiveSubscription && $team) {
|
||||
$team->subscriptionEnded($inactiveSubscription);
|
||||
}
|
||||
|
||||
$subscription->delete();
|
||||
$fixedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($team) {
|
||||
$team->subscriptionEnded($subscription);
|
||||
} else {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
}
|
||||
$fixedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->fix && count($discrepancies) > 0) {
|
||||
if ($fix && $fixedCount > 0) {
|
||||
send_internal_notification(
|
||||
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
|
||||
"SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n".
|
||||
json_encode($discrepancies, JSON_PRETTY_PRINT)
|
||||
);
|
||||
}
|
||||
|
|
@ -114,7 +157,9 @@ public function handle(?\Closure $onProgress = null): array
|
|||
'discrepancies' => $discrepancies,
|
||||
'resubscribed' => $resubscribed,
|
||||
'errors' => $errors,
|
||||
'fixed' => $this->fix,
|
||||
'fixed' => $fix,
|
||||
'fixed_count' => $fixedCount,
|
||||
'manual_review_count' => $manualReviewCount,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -183,13 +228,13 @@ private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure
|
|||
$validIds = [];
|
||||
$fetched = 0;
|
||||
|
||||
foreach (['active', 'past_due'] as $status) {
|
||||
foreach (self::VALID_STRIPE_STATUSES as $status) {
|
||||
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
|
||||
$validIds[] = $sub->id;
|
||||
$fetched++;
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($fetched);
|
||||
$onProgress('fetching', $fetched, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
app/Console/Commands/Cloud/CleanupUnverifiedUsers.php
Normal file
83
app/Console/Commands/Cloud/CleanupUnverifiedUsers.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CleanupUnverifiedUsers extends Command
|
||||
{
|
||||
protected $signature = 'cloud:cleanup-unverified-users
|
||||
{--yes : Delete eligible users instead of running a dry run}';
|
||||
|
||||
protected $description = 'Delete unverified users without Stripe subscriptions or defined resources';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on Coolify Cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$eligibleUsers = $this->eligibleUsers();
|
||||
$eligibleCount = $eligibleUsers->count();
|
||||
|
||||
$this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.');
|
||||
$shouldDelete = (bool) $this->option('yes');
|
||||
|
||||
if (! $shouldDelete) {
|
||||
$this->warn('Dry run only. Use --yes to delete eligible users.');
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
if ($eligibleCount > 0) {
|
||||
$progressAction = $shouldDelete ? 'Deleting' : 'Checking';
|
||||
$progressBar = $this->output->createProgressBar($eligibleCount);
|
||||
$progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%");
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($eligibleUsers->lazyById(100) as $user) {
|
||||
if ($shouldDelete && $user->delete()) {
|
||||
$deletedCount++;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
}
|
||||
|
||||
if ($shouldDelete) {
|
||||
$this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function eligibleUsers(): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('id', '!=', 0)
|
||||
->whereNull('email_verified_at')
|
||||
->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0))
|
||||
->whereDoesntHave('teams.subscription')
|
||||
->whereDoesntHave('teams.servers')
|
||||
->whereDoesntHave('teams', function (Builder $query) {
|
||||
$query->whereHas('projects.applications')
|
||||
->orWhereHas('projects.postgresqls')
|
||||
->orWhereHas('projects.redis')
|
||||
->orWhereHas('projects.mongodbs')
|
||||
->orWhereHas('projects.mysqls')
|
||||
->orWhereHas('projects.mariadbs')
|
||||
->orWhereHas('projects.keydbs')
|
||||
->orWhereHas('projects.dragonflies')
|
||||
->orWhereHas('projects.clickhouses')
|
||||
->orWhereHas('projects.services');
|
||||
});
|
||||
}
|
||||
}
|
||||
127
app/Console/Commands/Cloud/ExportUsers.php
Normal file
127
app/Console/Commands/Cloud/ExportUsers.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ExportUsers extends Command
|
||||
{
|
||||
protected $signature = 'cloud:export-users';
|
||||
|
||||
protected $description = 'Export subscribed and unsubscribed verified Coolify Cloud users to separate CSV files';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on Coolify Cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$backups = Storage::disk('backups');
|
||||
$backups->delete('cloud-users.csv');
|
||||
|
||||
$subscribedPath = $backups->path('cloud-users-subscribed.csv');
|
||||
$unsubscribedPath = $backups->path('cloud-users-unsubscribed.csv');
|
||||
$subscribedOutput = fopen($subscribedPath, 'wb');
|
||||
|
||||
if ($subscribedOutput === false) {
|
||||
$this->error("Unable to open {$subscribedPath} for writing.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$unsubscribedOutput = fopen($unsubscribedPath, 'wb');
|
||||
|
||||
if ($unsubscribedOutput === false) {
|
||||
fclose($subscribedOutput);
|
||||
$this->error("Unable to open {$unsubscribedPath} for writing.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$subscribedCount = 0;
|
||||
$unsubscribedCount = 0;
|
||||
|
||||
try {
|
||||
$header = [
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'lifetime_value_currency',
|
||||
'lifetime_value_amount',
|
||||
'utm_campaign',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'phone',
|
||||
];
|
||||
|
||||
$this->writeCsvRow($subscribedOutput, $header);
|
||||
$this->writeCsvRow($unsubscribedOutput, $header);
|
||||
|
||||
foreach (User::query()
|
||||
->select(['id', 'email', 'name'])
|
||||
->where('id', '!=', 0)
|
||||
->whereNotNull('email_verified_at')
|
||||
->withExists([
|
||||
'teams as is_subscribed' => fn ($query) => $query
|
||||
->whereRelation('subscription', 'stripe_invoice_paid', true),
|
||||
])
|
||||
->lazyById(500) as $user) {
|
||||
$nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: [];
|
||||
[$firstName, $lastName] = array_pad($nameParts, 2, '');
|
||||
|
||||
$row = [
|
||||
$user->email,
|
||||
$firstName,
|
||||
$lastName,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
if ($user->is_subscribed) {
|
||||
$this->writeCsvRow($subscribedOutput, $row);
|
||||
$subscribedCount++;
|
||||
} else {
|
||||
$this->writeCsvRow($unsubscribedOutput, $row);
|
||||
$unsubscribedCount++;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error("Unable to export users: {$exception->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
fclose($subscribedOutput);
|
||||
fclose($unsubscribedOutput);
|
||||
}
|
||||
|
||||
$this->info("Exported {$subscribedCount} subscribed verified users to {$subscribedPath}");
|
||||
$this->info("Exported {$unsubscribedCount} unsubscribed verified users to {$unsubscribedPath}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $output
|
||||
* @param array<int, mixed> $fields
|
||||
*/
|
||||
private function writeCsvRow($output, array $fields): void
|
||||
{
|
||||
if (fputcsv($output, $fields, ',', '"', '') === false) {
|
||||
throw new RuntimeException('Unable to write the CSV file.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Jobs\SyncStripeSubscriptionsJob;
|
||||
use App\Actions\Stripe\SyncStripeSubscriptions as SyncStripeSubscriptionsAction;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncStripeSubscriptions extends Command
|
||||
|
|
@ -35,14 +35,18 @@ public function handle(): int
|
|||
|
||||
$this->newLine();
|
||||
|
||||
$job = new SyncStripeSubscriptionsJob($fix);
|
||||
$fetched = 0;
|
||||
$result = $job->handle(function (int $count) use (&$fetched): void {
|
||||
$fetched = $count;
|
||||
$this->output->write("\r Fetching subscriptions from Stripe... {$fetched}");
|
||||
$progressShown = false;
|
||||
$result = SyncStripeSubscriptionsAction::run($fix, function (string $stage, int $current, ?int $total) use (&$progressShown): void {
|
||||
$progressShown = true;
|
||||
$message = match ($stage) {
|
||||
'checking' => " Checking stale subscriptions against Stripe... {$current}/{$total}",
|
||||
default => " Fetching valid subscriptions from Stripe... {$current}",
|
||||
};
|
||||
|
||||
$this->output->write("\r".str_pad($message, 80));
|
||||
});
|
||||
if ($fetched > 0) {
|
||||
$this->output->write("\r".str_repeat(' ', 60)."\r");
|
||||
if ($progressShown) {
|
||||
$this->output->write("\r".str_repeat(' ', 80)."\r");
|
||||
}
|
||||
|
||||
if (isset($result['error'])) {
|
||||
|
|
@ -63,13 +67,22 @@ public function handle(): int
|
|||
$this->line(" Team ID: {$discrepancy['team_id']}");
|
||||
$this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}");
|
||||
$this->line(" Stripe Status: {$discrepancy['stripe_status']}");
|
||||
$resolution = match ($discrepancy['resolution']) {
|
||||
'delete_stale' => 'Delete stale local row',
|
||||
'manual_review' => 'Manual review required',
|
||||
default => 'End local subscription',
|
||||
};
|
||||
$this->line(" Resolution: {$resolution}");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($fix) {
|
||||
$this->info('All discrepancies have been fixed.');
|
||||
$this->info("Automatic corrections applied: {$result['fixed_count']}");
|
||||
if ($result['manual_review_count'] > 0) {
|
||||
$this->warn("Skipped for manual review: {$result['manual_review_count']}");
|
||||
}
|
||||
} else {
|
||||
$this->comment('Run with --fix to correct these discrepancies.');
|
||||
$this->comment('Run with --fix to apply automatic corrections.');
|
||||
}
|
||||
} else {
|
||||
$this->info('No discrepancies found. All subscriptions are in sync.');
|
||||
|
|
@ -84,6 +97,7 @@ public function handle(): int
|
|||
$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->line(' Linked to this team: '.($resub['linked_to_team'] ? 'Yes' : 'No'));
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -850,6 +850,12 @@ public function create_backup(Request $request)
|
|||
|
||||
$this->authorize('manageBackups', $database);
|
||||
|
||||
if (! $database->isBackupSolutionAvailable()) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled backups are not supported for this database type.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate frequency is a valid cron expression
|
||||
$isValid = validate_cron_expression($request->frequency);
|
||||
if (! $isValid) {
|
||||
|
|
@ -915,6 +921,8 @@ public function create_backup(Request $request)
|
|||
$backupData['databases_to_backup'] = $database->mysql_database;
|
||||
} elseif ($database->type() === 'standalone-mariadb') {
|
||||
$backupData['databases_to_backup'] = $database->mariadb_database;
|
||||
} elseif ($database->type() === 'standalone-clickhouse') {
|
||||
$backupData['databases_to_backup'] = $database->clickhouse_db;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3016,7 +3024,7 @@ public function list_backup_executions(Request $request)
|
|||
),
|
||||
]
|
||||
)]
|
||||
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function move_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
use App\Notifications\Database\BackupSuccess;
|
||||
use App\Notifications\Database\BackupSuccessWithS3Warning;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Support\ClickhouseBackupCommand;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -39,7 +41,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public Server $server;
|
||||
|
||||
public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|ServiceDatabase $database;
|
||||
public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneClickhouse|ServiceDatabase $database;
|
||||
|
||||
public ?string $container_name = null;
|
||||
|
||||
|
|
@ -271,6 +273,8 @@ public function handle(): void
|
|||
$databasesToBackup = [$this->database->mysql_database];
|
||||
} elseif (str($databaseType)->contains('mariadb')) {
|
||||
$databasesToBackup = [$this->database->mariadb_database];
|
||||
} elseif ($this->database instanceof StandaloneClickhouse) {
|
||||
$databasesToBackup = [$this->database->clickhouse_db];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
|
@ -294,6 +298,10 @@ public function handle(): void
|
|||
// Format: db1,db2,db3
|
||||
$databasesToBackup = explode(',', $databasesToBackup);
|
||||
$databasesToBackup = array_map('trim', $databasesToBackup);
|
||||
} elseif ($this->database instanceof StandaloneClickhouse) {
|
||||
// Format: db1,db2,db3
|
||||
$databasesToBackup = explode(',', $databasesToBackup);
|
||||
$databasesToBackup = array_map('trim', $databasesToBackup);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
|
@ -386,6 +394,17 @@ public function handle(): void
|
|||
'local_storage_deleted' => false,
|
||||
]);
|
||||
$this->backup_standalone_mariadb($database);
|
||||
} elseif ($this->database instanceof StandaloneClickhouse) {
|
||||
$this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
|
||||
$this->backup_location = $this->backup_dir.$this->backup_file;
|
||||
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => $this->backup_log_uuid,
|
||||
'database_name' => $database,
|
||||
'filename' => $this->backup_location,
|
||||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
$this->backup_standalone_clickhouse($database);
|
||||
} else {
|
||||
throw new \Exception('Unsupported database type');
|
||||
}
|
||||
|
|
@ -400,6 +419,9 @@ public function handle(): void
|
|||
}
|
||||
} catch (Throwable $e) {
|
||||
// Local backup failed
|
||||
if ($this->database instanceof StandaloneClickhouse) {
|
||||
deleteBackupsLocally($this->backup_location, $this->server);
|
||||
}
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
|
|
@ -642,6 +664,32 @@ private function backup_standalone_mariadb(string $database): void
|
|||
}
|
||||
}
|
||||
|
||||
private function backup_standalone_clickhouse(string $database): void
|
||||
{
|
||||
$archiveName = ltrim($this->backup_file, '/');
|
||||
|
||||
try {
|
||||
$commands = ClickhouseBackupCommand::make(
|
||||
containerName: $this->container_name,
|
||||
database: $database,
|
||||
archiveName: $archiveName,
|
||||
backupDirectory: $this->backup_dir,
|
||||
);
|
||||
|
||||
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||
$this->backup_output = trim($this->backup_output);
|
||||
if ($this->backup_output === '') {
|
||||
$this->backup_output = null;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
$cleanupCommand = ClickhouseBackupCommand::cleanup($this->container_name, $archiveName);
|
||||
instant_remote_process([$cleanupCommand], $this->server, false, false, null, disableMultiplexing: true);
|
||||
}
|
||||
}
|
||||
|
||||
private function add_to_backup_output($output): void
|
||||
{
|
||||
if ($this->backup_output) {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,7 @@ public function mount()
|
|||
if (! $database) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
// No backups
|
||||
if (
|
||||
$database->getMorphClass() === \App\Models\StandaloneRedis::class ||
|
||||
$database->getMorphClass() === \App\Models\StandaloneKeydb::class ||
|
||||
$database->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
|
||||
$database->getMorphClass() === \App\Models\StandaloneClickhouse::class
|
||||
) {
|
||||
if (! $database->isBackupSolutionAvailable()) {
|
||||
return redirect()->route('project.database.configuration', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('manageBackups', $this->database);
|
||||
|
||||
if (! $this->database->isBackupSolutionAvailable()) {
|
||||
$this->dispatch('error', 'Scheduled backups are not supported for this database type.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
if ($this->saveToS3) {
|
||||
|
|
@ -87,6 +93,8 @@ public function submit()
|
|||
$payload['databases_to_backup'] = $this->database->mysql_database;
|
||||
} elseif ($this->database->type() === 'standalone-mariadb') {
|
||||
$payload['databases_to_backup'] = $this->database->mariadb_database;
|
||||
} elseif ($this->database->type() === 'standalone-clickhouse') {
|
||||
$payload['databases_to_backup'] = $this->database->clickhouse_db;
|
||||
}
|
||||
|
||||
$databaseBackup = ScheduledDatabaseBackup::create($payload);
|
||||
|
|
|
|||
|
|
@ -370,6 +370,6 @@ public function scheduledBackups()
|
|||
|
||||
public function isBackupSolutionAvailable()
|
||||
{
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,13 +219,15 @@ public function isAnyNotificationEnabled()
|
|||
$this->getNotificationSettings('webhook')?->isEnabled();
|
||||
}
|
||||
|
||||
public function subscriptionEnded()
|
||||
public function subscriptionEnded(?Subscription $subscription = null): void
|
||||
{
|
||||
if (! $this->subscription) {
|
||||
$subscription ??= $this->subscription;
|
||||
|
||||
if (! $subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->subscription->update([
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => null,
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
|
|
|
|||
37
app/Support/ClickhouseBackupCommand.php
Normal file
37
app/Support/ClickhouseBackupCommand.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class ClickhouseBackupCommand
|
||||
{
|
||||
/** @return array<int, string> */
|
||||
public static function make(
|
||||
string $containerName,
|
||||
string $database,
|
||||
string $archiveName,
|
||||
string $backupDirectory,
|
||||
): array {
|
||||
validateShellSafePath($database, 'database name');
|
||||
validateFilenameSafe($archiveName, 'ClickHouse backup archive');
|
||||
|
||||
$backupDirectory = rtrim($backupDirectory, '/');
|
||||
$containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName;
|
||||
$backupLocation = $backupDirectory.'/'.$archiveName;
|
||||
$query = "BACKUP DATABASE `{$database}` TO File('{$archiveName}')";
|
||||
|
||||
return [
|
||||
'mkdir -p '.escapeshellarg($backupDirectory),
|
||||
'docker exec '.escapeshellarg($containerName).' clickhouse-client --query '.escapeshellarg($query),
|
||||
'docker cp '.escapeshellarg($containerName.':'.$containerBackupPath).' '.escapeshellarg($backupLocation),
|
||||
];
|
||||
}
|
||||
|
||||
public static function cleanup(string $containerName, string $archiveName): string
|
||||
{
|
||||
validateFilenameSafe($archiveName, 'ClickHouse backup archive');
|
||||
|
||||
$containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName;
|
||||
|
||||
return 'docker exec '.escapeshellarg($containerName).' rm -f '.escapeshellarg($containerBackupPath);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<div class="box-title">{{ $user->name }}</div>
|
||||
<div class="box-description">{{ $user->email }}</div>
|
||||
<div class="box-description">Active:
|
||||
{{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }}
|
||||
{{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@
|
|||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@endif
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneClickhouse')
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
|
|
|
|||
|
|
@ -8,12 +8,7 @@
|
|||
'label' => 'Backups',
|
||||
'route' => 'project.database.backup.index',
|
||||
'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'),
|
||||
'visible' => in_array($database->getMorphClass(), [
|
||||
'App\Models\StandalonePostgresql',
|
||||
'App\Models\StandaloneMongodb',
|
||||
'App\Models\StandaloneMysql',
|
||||
'App\Models\StandaloneMariadb',
|
||||
]),
|
||||
'visible' => $database->isBackupSolutionAvailable(),
|
||||
],
|
||||
];
|
||||
|
||||
|
|
@ -200,11 +195,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Terminal
|
||||
</a>
|
||||
@endcan
|
||||
@if (
|
||||
$database->getMorphClass() === 'App\Models\StandalonePostgresql' ||
|
||||
$database->getMorphClass() === 'App\Models\StandaloneMongodb' ||
|
||||
$database->getMorphClass() === 'App\Models\StandaloneMysql' ||
|
||||
$database->getMorphClass() === 'App\Models\StandaloneMariadb')
|
||||
@if ($database->isBackupSolutionAvailable())
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.backup.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.database.backup.index', $parameters) }}">
|
||||
Backups
|
||||
|
|
|
|||
47
tests/Feature/AdminSubscriptionStatusTest.php
Normal file
47
tests/Feature/AdminSubscriptionStatusTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Index as AdminIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('admin search only shows users with paid subscriptions as active', function () {
|
||||
config()->set('cache.default', 'array');
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(
|
||||
fn () => InstanceSettings::query()->firstOrCreate(['id' => 0])
|
||||
);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$rootUser = User::find(0) ?? User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->syncWithoutDetaching([
|
||||
$rootUser->id => ['role' => 'admin'],
|
||||
]);
|
||||
|
||||
$inactiveUser = User::factory()->create(['email' => 'inactive@example.com']);
|
||||
$inactiveTeam = Team::factory()->create();
|
||||
$inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']);
|
||||
Subscription::create([
|
||||
'team_id' => $inactiveTeam->id,
|
||||
'stripe_subscription_id' => 'sub_stale',
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->set([
|
||||
'foundUsers' => collect(),
|
||||
'search' => 'inactive@example.com',
|
||||
])
|
||||
->call('submitSearch')
|
||||
->assertSee('No')
|
||||
->assertDontSee('Yes');
|
||||
});
|
||||
|
|
@ -5,8 +5,10 @@
|
|||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -15,7 +17,7 @@
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
|
@ -77,6 +79,56 @@ function backupHeaders(): array
|
|||
}
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
test('rejects backup configurations for unsupported database types', function () {
|
||||
$database = StandaloneRedis::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Redis DB',
|
||||
'status' => 'running',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(backupHeaders())
|
||||
->postJson("/api/v1/databases/{$database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJson([
|
||||
'message' => 'Scheduled backups are not supported for this database type.',
|
||||
]);
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('defaults clickhouse backups to its configured database', function () {
|
||||
$database = StandaloneClickhouse::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'ClickHouse DB',
|
||||
'clickhouse_admin_user' => 'default',
|
||||
'clickhouse_admin_password' => 'password',
|
||||
'clickhouse_db' => 'analytics',
|
||||
'image' => 'clickhouse/clickhouse-server:25.11',
|
||||
'status' => 'running',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(backupHeaders())
|
||||
->postJson("/api/v1/databases/{$database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($backup->databases_to_backup)->toBe('analytics')
|
||||
->and($backup->database_type)->toBe(StandaloneClickhouse::class);
|
||||
});
|
||||
|
||||
test('creates backup configuration with valid frequency', function () {
|
||||
$response = $this->withHeaders(backupHeaders())
|
||||
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
|
|
|
|||
154
tests/Feature/CleanupUnverifiedUsersCommandTest.php
Normal file
154
tests/Feature/CleanupUnverifiedUsersCommandTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
});
|
||||
|
||||
test('it previews eligible unverified users without deleting them', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'email' => 'unverified@example.com',
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users')
|
||||
->expectsOutput('Found 1 unverified user eligible for deletion.')
|
||||
->expectsOutput('Dry run only. Use --yes to delete eligible users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($user);
|
||||
});
|
||||
|
||||
test('it reports dry run progress', function () {
|
||||
$users = User::factory()->unverified()->count(2)->create();
|
||||
|
||||
$exitCode = Artisan::call('cloud:cleanup-unverified-users');
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and(Artisan::output())->toContain('Checking eligible users: 2/2');
|
||||
|
||||
$users->each(fn (User $user) => $this->assertModelExists($user));
|
||||
});
|
||||
|
||||
test('it deletes eligible unverified users with the yes option', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'email' => 'unverified@example.com',
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
|
||||
->expectsOutput('Deleted 1 unverified user.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelMissing($user);
|
||||
});
|
||||
|
||||
test('it reports deletion progress', function () {
|
||||
User::factory()->unverified()->count(2)->create();
|
||||
|
||||
$exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]);
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and(Artisan::output())->toContain('Deleting eligible users: 2/2');
|
||||
});
|
||||
|
||||
test('it keeps verified users', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'verified@example.com',
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
|
||||
->expectsOutput('Deleted 0 unverified users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($user);
|
||||
});
|
||||
|
||||
test('it keeps unverified users with a Stripe subscription record', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'email' => 'subscribed@example.com',
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $user->teams()->firstOrFail()->id,
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
|
||||
->expectsOutput('Deleted 0 unverified users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($user);
|
||||
});
|
||||
|
||||
test('it keeps unverified users with defined resources', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'email' => 'resource-owner@example.com',
|
||||
]);
|
||||
|
||||
$project = Project::factory()->create([
|
||||
'team_id' => $user->teams()->firstOrFail()->id,
|
||||
]);
|
||||
$environment = Environment::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
|
||||
->expectsOutput('Deleted 0 unverified users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($user);
|
||||
});
|
||||
|
||||
test('it keeps unverified users with servers', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'email' => 'server-owner@example.com',
|
||||
]);
|
||||
|
||||
Server::factory()->create([
|
||||
'team_id' => $user->teams()->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
|
||||
->expectsOutput('Deleted 0 unverified users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($user);
|
||||
});
|
||||
|
||||
test('it keeps unverified root team members', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'email' => 'root-member@example.com',
|
||||
]);
|
||||
$rootTeam = Team::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root Team',
|
||||
]);
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
|
||||
->expectsOutput('Deleted 0 unverified users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($user);
|
||||
});
|
||||
|
||||
test('it only runs on Coolify Cloud', function () {
|
||||
config()->set('constants.coolify.self_hosted', true);
|
||||
|
||||
$this->artisan('cloud:cleanup-unverified-users')
|
||||
->expectsOutput('This command can only be run on Coolify Cloud.')
|
||||
->assertFailed();
|
||||
});
|
||||
101
tests/Feature/CloudExportUsersCommandTest.php
Normal file
101
tests/Feature/CloudExportUsersCommandTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('backups');
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
});
|
||||
|
||||
test('it exports subscribed and unsubscribed verified users to separate files without tags', function () {
|
||||
User::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'root@example.com',
|
||||
]);
|
||||
|
||||
$subscribedUser = User::factory()->create([
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $subscribedUser->teams()->firstOrFail()->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_subscription_id' => 'sub_active',
|
||||
]);
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Grace',
|
||||
'email' => 'grace@example.com',
|
||||
]);
|
||||
|
||||
User::factory()->unverified()->create([
|
||||
'name' => 'Unverified User',
|
||||
'email' => 'unverified@example.com',
|
||||
]);
|
||||
|
||||
Storage::disk('backups')->put('cloud-users.csv', 'old export');
|
||||
|
||||
$this->artisan('cloud:export-users')->assertSuccessful();
|
||||
|
||||
Storage::disk('backups')->assertExists([
|
||||
'cloud-users-subscribed.csv',
|
||||
'cloud-users-unsubscribed.csv',
|
||||
]);
|
||||
Storage::disk('backups')->assertMissing('cloud-users.csv');
|
||||
|
||||
$readCsv = function (string $filename): array {
|
||||
$output = fopen(Storage::disk('backups')->path($filename), 'rb');
|
||||
$rows = [];
|
||||
|
||||
while (($row = fgetcsv($output, null, ',', '"', '')) !== false) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
|
||||
return $rows;
|
||||
};
|
||||
|
||||
$header = [
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'lifetime_value_currency',
|
||||
'lifetime_value_amount',
|
||||
'utm_campaign',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'phone',
|
||||
];
|
||||
|
||||
expect($readCsv('cloud-users-subscribed.csv'))->toBe([
|
||||
$header,
|
||||
['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''],
|
||||
])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([
|
||||
$header,
|
||||
['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('it only runs on Coolify Cloud', function () {
|
||||
config()->set('constants.coolify.self_hosted', true);
|
||||
|
||||
$this->artisan('cloud:export-users')
|
||||
->expectsOutput('This command can only be run on Coolify Cloud.')
|
||||
->assertFailed();
|
||||
|
||||
Storage::disk('backups')->assertMissing([
|
||||
'cloud-users.csv',
|
||||
'cloud-users-subscribed.csv',
|
||||
'cloud-users-unsubscribed.csv',
|
||||
]);
|
||||
});
|
||||
|
|
@ -6,8 +6,10 @@
|
|||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -136,3 +138,49 @@ function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
|
|||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
});
|
||||
|
||||
it('creates a clickhouse backup for its configured database', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandaloneClickhouse::create([
|
||||
'name' => 'clickhouse-scheduled-backup',
|
||||
'clickhouse_admin_user' => 'default',
|
||||
'clickhouse_admin_password' => 'password',
|
||||
'clickhouse_db' => 'analytics',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::firstOrFail();
|
||||
|
||||
expect($backup->database_type)->toBe(StandaloneClickhouse::class)
|
||||
->and($backup->databases_to_backup)->toBe('analytics');
|
||||
});
|
||||
|
||||
it('rejects scheduled backups for unsupported database types', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandaloneRedis::create([
|
||||
'name' => 'redis-without-backups',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,3 +48,13 @@
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not schedule Stripe subscription reconciliation automatically', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())->first(
|
||||
fn ($event) => str_contains((string) $event->description, 'SyncStripeSubscriptions')
|
||||
);
|
||||
|
||||
expect($event)->toBeNull();
|
||||
});
|
||||
|
|
|
|||
296
tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php
Normal file
296
tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Stripe\SyncStripeSubscriptions;
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Collection as StripeCollection;
|
||||
use Stripe\Service\CustomerService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_stale',
|
||||
'stripe_customer_id' => 'cus_stale',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
SyncStripeSubscriptions::clearFake();
|
||||
});
|
||||
|
||||
function stripeSubscriptionCollection(array $subscriptions = []): StripeCollection
|
||||
{
|
||||
return StripeCollection::constructFrom([
|
||||
'data' => $subscriptions,
|
||||
'has_more' => false,
|
||||
'url' => '/v1/subscriptions',
|
||||
]);
|
||||
}
|
||||
|
||||
test('fix mode fully ends a locally active subscription with an invalid Stripe status', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$customers = Mockery::mock(CustomerService::class);
|
||||
$stripe->subscriptions = $subscriptions;
|
||||
$stripe->customers = $customers;
|
||||
|
||||
$subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection());
|
||||
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
||||
'status' => 'unpaid',
|
||||
'customer' => 'cus_stale',
|
||||
]);
|
||||
$customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable'));
|
||||
|
||||
$this->instance(StripeClient::class, $stripe);
|
||||
|
||||
$progress = [];
|
||||
|
||||
SyncStripeSubscriptions::run(
|
||||
fix: true,
|
||||
onProgress: function (string $stage, int $current, ?int $total) use (&$progress): void {
|
||||
$progress[] = [$stage, $current, $total];
|
||||
},
|
||||
);
|
||||
|
||||
$this->subscription->refresh();
|
||||
$server->refresh();
|
||||
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy()
|
||||
->and($this->subscription->stripe_subscription_id)->toBeNull()
|
||||
->and($server->unreachable_count)->toBe(3)
|
||||
->and($server->unreachable_notification_sent)->toBeTrue()
|
||||
->and($progress)->toContain(['checking', 0, 1])
|
||||
->and($progress)->toContain(['checking', 1, 1]);
|
||||
});
|
||||
|
||||
test('a same-email subscription not linked to the team is left for manual review', function () {
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$customers = Mockery::mock(CustomerService::class);
|
||||
$stripe->subscriptions = $subscriptions;
|
||||
$stripe->customers = $customers;
|
||||
|
||||
$subscriptions->shouldReceive('all')
|
||||
->with(Mockery::on(fn (array $parameters) => isset($parameters['status'])))
|
||||
->twice()
|
||||
->andReturn(stripeSubscriptionCollection());
|
||||
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
||||
'status' => 'canceled',
|
||||
'customer' => 'cus_stale',
|
||||
]);
|
||||
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [
|
||||
'email' => 'customer@example.com',
|
||||
]);
|
||||
$customers->shouldReceive('all')->andReturn((object) [
|
||||
'data' => [(object) ['id' => 'cus_active']],
|
||||
]);
|
||||
$subscriptions->shouldReceive('all')->with([
|
||||
'customer' => 'cus_active',
|
||||
'limit' => 10,
|
||||
])->andReturn((object) [
|
||||
'data' => [(object) [
|
||||
'id' => 'sub_active_elsewhere',
|
||||
'status' => 'active',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->instance(StripeClient::class, $stripe);
|
||||
|
||||
$result = SyncStripeSubscriptions::run(fix: true);
|
||||
|
||||
$this->subscription->refresh();
|
||||
|
||||
expect($result['resubscribed'])->toHaveCount(1)
|
||||
->and($result['discrepancies'])->toHaveCount(1)
|
||||
->and($result['discrepancies'][0]['resolution'])->toBe('manual_review')
|
||||
->and($this->subscription->stripe_invoice_paid)->toBeTruthy()
|
||||
->and($this->subscription->stripe_subscription_id)->toBe('sub_stale');
|
||||
});
|
||||
|
||||
test('a stale local subscription is deleted when its team has a valid replacement', function () {
|
||||
$replacement = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_active_elsewhere',
|
||||
'stripe_customer_id' => 'cus_active',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$customers = Mockery::mock(CustomerService::class);
|
||||
$stripe->subscriptions = $subscriptions;
|
||||
$stripe->customers = $customers;
|
||||
|
||||
$subscriptions->shouldReceive('all')
|
||||
->with(['status' => 'active', 'limit' => 100])
|
||||
->andReturn(stripeSubscriptionCollection([
|
||||
['id' => 'sub_active_elsewhere'],
|
||||
]));
|
||||
$subscriptions->shouldReceive('all')
|
||||
->with(['status' => 'past_due', 'limit' => 100])
|
||||
->andReturn(stripeSubscriptionCollection());
|
||||
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
||||
'status' => 'canceled',
|
||||
'customer' => 'cus_stale',
|
||||
]);
|
||||
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [
|
||||
'email' => 'customer@example.com',
|
||||
]);
|
||||
$customers->shouldReceive('all')->andReturn((object) [
|
||||
'data' => [(object) ['id' => 'cus_active']],
|
||||
]);
|
||||
$subscriptions->shouldReceive('all')->with([
|
||||
'customer' => 'cus_active',
|
||||
'limit' => 10,
|
||||
])->andReturn((object) [
|
||||
'data' => [(object) [
|
||||
'id' => 'sub_active_elsewhere',
|
||||
'status' => 'active',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->instance(StripeClient::class, $stripe);
|
||||
|
||||
$result = SyncStripeSubscriptions::run(fix: true);
|
||||
|
||||
expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale')
|
||||
->and($this->subscription->fresh())->toBeNull()
|
||||
->and($replacement->fresh())->not->toBeNull()
|
||||
->and($replacement->fresh()->stripe_invoice_paid)->toBeTruthy()
|
||||
->and($server->settings->fresh()->is_reachable)->toBeTruthy()
|
||||
->and($server->settings->fresh()->is_usable)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('a stale duplicate is deleted when its team already has an inactive subscription row', function () {
|
||||
$inactiveSubscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_customer_id' => 'cus_stale',
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$customers = Mockery::mock(CustomerService::class);
|
||||
$stripe->subscriptions = $subscriptions;
|
||||
$stripe->customers = $customers;
|
||||
|
||||
$subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection());
|
||||
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
||||
'status' => 'canceled',
|
||||
'customer' => 'cus_stale',
|
||||
]);
|
||||
$customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable'));
|
||||
|
||||
$this->instance(StripeClient::class, $stripe);
|
||||
|
||||
$result = SyncStripeSubscriptions::run(fix: true);
|
||||
|
||||
expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale')
|
||||
->and($this->subscription->fresh())->toBeNull()
|
||||
->and($inactiveSubscription->fresh())->not->toBeNull()
|
||||
->and($server->settings->fresh()->is_reachable)->toBeFalsy()
|
||||
->and($server->settings->fresh()->is_usable)->toBeFalsy();
|
||||
});
|
||||
|
||||
test('valid Stripe subscriptions remain active', function (string $status) {
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions = $subscriptions;
|
||||
|
||||
$subscriptions->shouldReceive('all')
|
||||
->with(['status' => $status, 'limit' => 100])
|
||||
->andReturn(stripeSubscriptionCollection([
|
||||
['id' => 'sub_stale'],
|
||||
]));
|
||||
$subscriptions->shouldReceive('all')
|
||||
->with(['status' => $status === 'active' ? 'past_due' : 'active', 'limit' => 100])
|
||||
->andReturn(stripeSubscriptionCollection());
|
||||
$subscriptions->shouldNotReceive('retrieve');
|
||||
|
||||
$this->instance(StripeClient::class, $stripe);
|
||||
|
||||
$result = SyncStripeSubscriptions::run(fix: true);
|
||||
|
||||
$this->subscription->refresh();
|
||||
|
||||
expect($result['discrepancies'])->toBeEmpty()
|
||||
->and($this->subscription->stripe_invoice_paid)->toBeTruthy()
|
||||
->and($this->subscription->stripe_subscription_id)->toBe('sub_stale');
|
||||
})->with(['active', 'past_due']);
|
||||
|
||||
test('the terminal command runs the reconciliation action synchronously', function () {
|
||||
SyncStripeSubscriptions::shouldRun()
|
||||
->once()
|
||||
->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === false && $onProgress instanceof Closure)
|
||||
->andReturnUsing(function (bool $fix, Closure $onProgress): array {
|
||||
$onProgress('checking', 2, 10);
|
||||
|
||||
return [
|
||||
'total_checked' => 0,
|
||||
'discrepancies' => [],
|
||||
'resubscribed' => [],
|
||||
'errors' => [],
|
||||
'fixed' => false,
|
||||
];
|
||||
});
|
||||
|
||||
$this->artisan('cloud:sync-stripe-subscriptions')
|
||||
->expectsOutputToContain('Checking stale subscriptions against Stripe... 2/10')
|
||||
->expectsOutput('Total subscriptions checked: 0')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the terminal command passes the fix option to the reconciliation action', function () {
|
||||
SyncStripeSubscriptions::shouldRun()
|
||||
->once()
|
||||
->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === true && $onProgress instanceof Closure)
|
||||
->andReturn([
|
||||
'total_checked' => 1,
|
||||
'discrepancies' => [[
|
||||
'subscription_id' => 1,
|
||||
'team_id' => 2,
|
||||
'stripe_subscription_id' => 'sub_stale',
|
||||
'stripe_status' => 'canceled',
|
||||
'resolution' => 'manual_review',
|
||||
]],
|
||||
'resubscribed' => [],
|
||||
'errors' => [],
|
||||
'fixed' => true,
|
||||
'fixed_count' => 0,
|
||||
'manual_review_count' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('cloud:sync-stripe-subscriptions', ['--fix' => true])
|
||||
->expectsOutput('Total subscriptions checked: 1')
|
||||
->expectsOutput(' Resolution: Manual review required')
|
||||
->expectsOutput('Automatic corrections applied: 0')
|
||||
->expectsOutput('Skipped for manual review: 1')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
|
|
@ -14,3 +15,24 @@
|
|||
// If we reach here, no exception was thrown
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('subscriptionEnded updates the exact subscription when duplicate rows exist', function () {
|
||||
$team = Team::factory()->create();
|
||||
$otherSubscription = Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_subscription_id' => 'sub_other',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$subscriptionToEnd = Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_subscription_id' => 'sub_stale',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded($subscriptionToEnd);
|
||||
|
||||
expect($subscriptionToEnd->fresh()->stripe_subscription_id)->toBeNull()
|
||||
->and($subscriptionToEnd->fresh()->stripe_invoice_paid)->toBeFalsy()
|
||||
->and($otherSubscription->fresh()->stripe_subscription_id)->toBe('sub_other')
|
||||
->and($otherSubscription->fresh()->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
|
|
|||
36
tests/Unit/ClickhouseBackupCommandTest.php
Normal file
36
tests/Unit/ClickhouseBackupCommandTest.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Support\ClickhouseBackupCommand;
|
||||
|
||||
test('clickhouse exposes scheduled backups', function () {
|
||||
expect((new StandaloneClickhouse)->isBackupSolutionAvailable())->toBeTrue();
|
||||
});
|
||||
|
||||
test('builds a native clickhouse backup command using container credentials', function () {
|
||||
$commands = ClickhouseBackupCommand::make(
|
||||
containerName: 'clickhouse-uuid',
|
||||
database: 'analytics',
|
||||
archiveName: 'backup-uuid.zip',
|
||||
backupDirectory: '/data/coolify/backups/clickhouse',
|
||||
);
|
||||
|
||||
expect($commands)->toHaveCount(3)
|
||||
->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'")
|
||||
->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client")
|
||||
->and($commands[1])->not->toContain('--password')
|
||||
->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('")
|
||||
->and($commands[1])->toContain('backup-uuid.zip')
|
||||
->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'")
|
||||
->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip'))
|
||||
->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'");
|
||||
});
|
||||
|
||||
test('rejects unsafe clickhouse database names', function () {
|
||||
expect(fn () => ClickhouseBackupCommand::make(
|
||||
containerName: 'clickhouse-uuid',
|
||||
database: 'analytics`; DROP DATABASE default; --',
|
||||
archiveName: 'backup-uuid.zip',
|
||||
backupDirectory: '/data/coolify/backups/clickhouse',
|
||||
))->toThrow(Exception::class);
|
||||
});
|
||||
Loading…
Reference in a new issue