Merge remote-tracking branch 'origin/next' into volume-backups-server-s3
This commit is contained in:
commit
ddbed9f8a6
38 changed files with 1634 additions and 134 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)) {
|
||||
|
|
|
|||
|
|
@ -490,7 +490,7 @@ public function logs_by_uuid(Request $request): JsonResponse
|
|||
], 400);
|
||||
}
|
||||
|
||||
$lines = (int) ($request->query('lines', 100) ?: 100);
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
$logs = getContainerLogs($server, $containerName, $lines);
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ public function logs(Request $request): JsonResponse
|
|||
return response()->json(['message' => 'Service database container is not running.'], 400);
|
||||
}
|
||||
|
||||
$lines = (int) ($request->query('lines', 100) ?: 100);
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
|
||||
return response()->json([
|
||||
'logs' => getContainerLogs($server, $containerName, $lines),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Services\VultrService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class VultrController extends Controller
|
||||
|
|
@ -286,6 +287,10 @@ public function createServer(Request $request): JsonResponse
|
|||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
$vultrService = null;
|
||||
$vultrInstanceId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$vultrService = new VultrService($token->token);
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
|
|
@ -317,33 +322,41 @@ public function createServer(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
$vultrInstance = $vultrService->createInstance($params);
|
||||
$vultrInstanceId = (string) $vultrInstance['id'];
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
|
||||
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$ipAddress = $assignedIpAddress;
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
$server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server {
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
|
|
@ -353,27 +366,48 @@ public function createServer(Request $request): JsonResponse
|
|||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'ip' => $ipAddress,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'ip' => $server->ip,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'ip' => $ipAddress,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'ip' => $server->ip,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
logger()->error('Failed to create Vultr server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
|
||||
{
|
||||
if (! $vultrService || ! $vultrInstanceId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vultrService->deleteInstance($vultrInstanceId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
|
||||
{
|
||||
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,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();
|
||||
|
||||
$isValid = validate_cron_expression($this->frequency);
|
||||
|
|
@ -51,6 +57,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);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -377,9 +378,8 @@ private function providerDataErrorMessage(string $providerName, \Throwable $e, s
|
|||
return "{$providerName} API error: {$details}";
|
||||
}
|
||||
|
||||
private function createVultrServer(string $token): array
|
||||
private function createVultrServer(VultrService $vultrService): array
|
||||
{
|
||||
$vultrService = new VultrService($token);
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
|
||||
|
|
@ -419,6 +419,10 @@ public function submit(): mixed
|
|||
return null;
|
||||
}
|
||||
|
||||
$vultrService = null;
|
||||
$vultrInstanceId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
|
|
@ -437,20 +441,29 @@ public function submit(): mixed
|
|||
}
|
||||
|
||||
$vultrService = new VultrService($this->getVultrToken());
|
||||
$vultrInstance = $this->createVultrServer($this->getVultrToken());
|
||||
$vultrInstance = $this->createVultrServer($vultrService);
|
||||
$vultrInstanceId = (string) $vultrInstance['id'];
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP;
|
||||
|
||||
$server = Server::create([
|
||||
'name' => strtolower(trim($this->server_name)),
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
$server = DB::transaction(function () use ($ipAddress, $vultrInstanceId, $vultrInstance): Server {
|
||||
$server = Server::create([
|
||||
'name' => strtolower(trim($this->server_name)),
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||
|
|
@ -466,10 +479,6 @@ public function submit(): mixed
|
|||
report($e);
|
||||
}
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
currentTeam()->update([
|
||||
'show_boarding' => false,
|
||||
|
|
@ -479,10 +488,25 @@ public function submit(): mixed
|
|||
|
||||
return redirectRoute($this, 'server.show', [$server->uuid]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
|
||||
{
|
||||
if (! $vultrService || ! $vultrInstanceId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vultrService->deleteInstance($vultrInstanceId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.new.by-vultr');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ private function request(string $method, string $endpoint, array $data = []): ar
|
|||
}
|
||||
|
||||
return $attempt * 100;
|
||||
})
|
||||
}, throw: false)
|
||||
->{$method}($this->baseUrl.$endpoint, $data);
|
||||
|
||||
if (! $response->successful()) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ private function request(string $method, string $endpoint, array $data = []): ar
|
|||
'Authorization' => 'Bearer '.$this->token,
|
||||
])
|
||||
->timeout(30)
|
||||
->retry(3, fn (int $attempt) => $attempt * 100)
|
||||
->retry(3, fn (int $attempt) => $attempt * 100, throw: false)
|
||||
->{$method}($this->baseUrl.$endpoint, $data);
|
||||
|
||||
if (! $response->successful()) {
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,12 @@ function normalize_email_identity(?string $email): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
[$localPart, $domain] = explode('@', Str::lower($email), 2);
|
||||
$localPart = Str::before($localPart, '+');
|
||||
$localPart = str_replace('.', '', $localPart);
|
||||
[$localPart, $domain] = explode('@', Str::lower(trim($email)), 2);
|
||||
|
||||
if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) {
|
||||
$localPart = Str::before($localPart, '+');
|
||||
$localPart = str_replace('.', '', $localPart);
|
||||
}
|
||||
|
||||
if (blank($localPart) || blank($domain)) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,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">
|
||||
|
|
|
|||
|
|
@ -6,12 +6,7 @@
|
|||
'label' => 'Backups',
|
||||
'route' => 'project.database.backup.index',
|
||||
'active' => request()->routeIs('project.database.backup.*'),
|
||||
'visible' => in_array($database->getMorphClass(), [
|
||||
'App\Models\StandalonePostgresql',
|
||||
'App\Models\StandaloneMongodb',
|
||||
'App\Models\StandaloneMysql',
|
||||
'App\Models\StandaloneMariadb',
|
||||
]),
|
||||
'visible' => $database->isBackupSolutionAvailable(),
|
||||
],
|
||||
['label' => 'Logs', 'route' => 'project.database.logs', 'active' => request()->routeIs('project.database.logs')],
|
||||
['label' => 'Terminal', 'route' => 'project.database.command', 'active' => request()->routeIs('project.database.command'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
|
|
@ -190,11 +185,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Configuration
|
||||
</a>
|
||||
|
||||
@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.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.database.backup.index', $parameters) }}">
|
||||
Backups
|
||||
|
|
@ -210,6 +201,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Terminal
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
</nav>
|
||||
|
||||
@if ($database->destination->server->isFunctional())
|
||||
|
|
|
|||
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',
|
||||
]);
|
||||
});
|
||||
|
|
@ -7,8 +7,10 @@
|
|||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
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;
|
||||
|
|
@ -91,3 +93,55 @@
|
|||
expect($backup->save_s3)->toBeFalsy()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
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(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::firstOrFail();
|
||||
|
||||
$component->assertRedirectToRoute('project.database.backup.execution', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@
|
|||
|
||||
it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () {
|
||||
$emails = [
|
||||
'ke.vinmcfadden+one@btinternet.com',
|
||||
'kevin.mcfadden+two@btinternet.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
|
||||
'ke.vinmcfadden+one@gmail.com',
|
||||
'kevin.mcfadden+two@gmail.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
|
|
@ -42,7 +42,24 @@
|
|||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
'email' => 'k.evin.mcfadden+four@gmail.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () {
|
||||
$emails = [
|
||||
'john.smith@example.com',
|
||||
'johnsmith@example.com',
|
||||
'johnsmith+one@example.com',
|
||||
'johnsmith+two@example.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)])
|
||||
->post('/forgot-password', [
|
||||
'email' => $email,
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
|
|
@ -42,9 +43,9 @@
|
|||
|
||||
it('rate limits dotted plus-address variants of the same email identity across ips', function () {
|
||||
$emails = [
|
||||
'ke.vinmcfadden+one@btinternet.com',
|
||||
'kevin.mcfadden+two@btinternet.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
|
||||
'ke.vinmcfadden+one@gmail.com',
|
||||
'kevin.mcfadden+two@gmail.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
|
|
@ -64,9 +65,33 @@
|
|||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
'email' => 'k.evin.mcfadden+four@gmail.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () {
|
||||
$registrationIpKey = 'registration:ip:'.sha1('127.0.0.1');
|
||||
$emails = [
|
||||
'john.smith@example.com',
|
||||
'johnsmith@example.com',
|
||||
'johnsmith+one@example.com',
|
||||
'johnsmith+two@example.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->post('/register', [
|
||||
'name' => "Distinct User {$index}",
|
||||
'email' => $email,
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
RateLimiter::clear($registrationIpKey);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -301,6 +301,104 @@ function testPublicKey(): string
|
|||
]);
|
||||
});
|
||||
|
||||
test('deletes the Vultr instance when local server persistence fails', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
|
||||
], 201),
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-persistence-fails',
|
||||
'main_ip' => '203.0.113.10',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
'https://api.vultr.com/v2/instances/instance-persistence-fails' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$eventDispatcher = Server::getEventDispatcher();
|
||||
Server::setEventDispatcher(clone $eventDispatcher);
|
||||
Server::created(function (): void {
|
||||
throw new RuntimeException('local persistence failed');
|
||||
});
|
||||
|
||||
try {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/vultr', [
|
||||
'cloud_provider_token_id' => $this->vultrToken->uuid,
|
||||
'region' => 'ewr',
|
||||
'plan' => 'vc2-1c-1gb',
|
||||
'os_id' => 2284,
|
||||
'name' => 'persistence-fails',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
} finally {
|
||||
Server::setEventDispatcher($eventDispatcher);
|
||||
}
|
||||
|
||||
$response->assertServerError();
|
||||
$response->assertJson(['message' => 'Failed to create Vultr server.']);
|
||||
$this->assertDatabaseMissing('servers', [
|
||||
'vultr_instance_id' => 'instance-persistence-fails',
|
||||
]);
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-persistence-fails');
|
||||
});
|
||||
|
||||
test('keeps the tracked Vultr server when public IP polling fails', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
|
||||
], 201),
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-polling-fails',
|
||||
'main_ip' => '0.0.0.0',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
'https://api.vultr.com/v2/instances/instance-polling-fails' => Http::response([
|
||||
'error' => 'temporary polling failure',
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/vultr', [
|
||||
'cloud_provider_token_id' => $this->vultrToken->uuid,
|
||||
'region' => 'ewr',
|
||||
'plan' => 'vc2-1c-1gb',
|
||||
'os_id' => 2284,
|
||||
'name' => 'polling-fails',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment([
|
||||
'vultr_instance_id' => 'instance-polling-fails',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
]);
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'polling-fails',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'vultr_instance_id' => 'instance-polling-fails',
|
||||
'vultr_instance_status' => 'pending',
|
||||
]);
|
||||
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
|
||||
});
|
||||
|
||||
test('server creation authorizes access to the stored Vultr token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -170,6 +171,56 @@ function vultrLivewireTestPublicKey(): string
|
|||
'vultr_instance_id' => 'instance-1',
|
||||
'vultr_instance_status' => 'pending',
|
||||
]);
|
||||
|
||||
Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
|
||||
});
|
||||
|
||||
it('deletes the Vultr instance when local server persistence fails', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
|
||||
], 201),
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'label' => 'persistence-fails',
|
||||
'main_ip' => '192.0.2.10',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$eventDispatcher = Server::getEventDispatcher();
|
||||
Server::setEventDispatcher(clone $eventDispatcher);
|
||||
Server::created(function (): void {
|
||||
throw new RuntimeException('local persistence failed');
|
||||
});
|
||||
|
||||
try {
|
||||
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||
->set('server_name', 'persistence-fails')
|
||||
->set('selected_region', 'ewr')
|
||||
->set('selected_plan', 'vc2-1c-1gb')
|
||||
->set('selected_os_id', 2284)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertDispatched('error', 'local persistence failed');
|
||||
} finally {
|
||||
Server::setEventDispatcher($eventDispatcher);
|
||||
}
|
||||
|
||||
$this->assertDatabaseMissing('servers', [
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
]);
|
||||
Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
|
||||
});
|
||||
|
||||
it('requires IPv6 when public IPv4 is disabled', function () {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@
|
|||
->and(normalizeLogLines('50000'))->toBe(10000);
|
||||
});
|
||||
|
||||
it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
|
||||
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
|
||||
|
||||
expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));')
|
||||
->and(normalizeLogLines($lines))->toBe($expectedLines);
|
||||
})->with([
|
||||
'application invalid value' => ['ServiceApplicationsController', 'invalid', 100],
|
||||
'application negative value' => ['ServiceApplicationsController', '-5', 100],
|
||||
'application value above limit' => ['ServiceApplicationsController', '50000', 10000],
|
||||
'database invalid value' => ['ServiceDatabasesController', 'invalid', 100],
|
||||
'database negative value' => ['ServiceDatabasesController', '-5', 100],
|
||||
'database value above limit' => ['ServiceDatabasesController', '50000', 10000],
|
||||
]);
|
||||
|
||||
it('parses show_timestamps query values as booleans', function () {
|
||||
if (! function_exists('parseLogTimestampFlag')) {
|
||||
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,11 +1,59 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('maps final DigitalOcean API error responses', function (int $status, string $message) {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response(['message' => $message], $status),
|
||||
]);
|
||||
|
||||
$exception = null;
|
||||
|
||||
try {
|
||||
(new DigitalOceanService('test-token'))->createDroplet([]);
|
||||
} catch (Throwable $caught) {
|
||||
$exception = $caught;
|
||||
}
|
||||
|
||||
expect($exception)->toBeInstanceOf(Exception::class)
|
||||
->and($exception)->not->toBeInstanceOf(RequestException::class)
|
||||
->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message)
|
||||
->and($exception->getCode())->toBe($status);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
})->with([
|
||||
'unauthorized' => [401, 'Unable to authenticate you'],
|
||||
'unprocessable entity' => [422, 'Droplet name is invalid'],
|
||||
]);
|
||||
|
||||
it('maps a final DigitalOcean rate-limit response with Retry-After', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::sequence()
|
||||
->push(['message' => 'Temporary provider error'], 500)
|
||||
->push(['message' => 'Temporary provider error'], 500)
|
||||
->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']),
|
||||
]);
|
||||
|
||||
try {
|
||||
(new DigitalOceanService('test-token'))->createDroplet([]);
|
||||
} catch (RateLimitException $exception) {
|
||||
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
|
||||
->and($exception->retryAfter)->toBe(45);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
test()->fail('Expected a RateLimitException to be thrown.');
|
||||
});
|
||||
|
||||
it('fetches paginated regions from DigitalOcean', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<?php
|
||||
|
||||
it('normalizes plus and dotted email identity variants', function () {
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com');
|
||||
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com');
|
||||
});
|
||||
|
||||
it('preserves dots and plus suffixes for ordinary email providers', function () {
|
||||
expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com');
|
||||
});
|
||||
|
||||
it('returns null for blank or malformed email identities', function (?string $email) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -10,6 +12,52 @@
|
|||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('maps final Vultr API error responses', function (int $status, string $message) {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances' => Http::response(['error' => $message], $status),
|
||||
]);
|
||||
|
||||
$exception = null;
|
||||
|
||||
try {
|
||||
(new VultrService('fake-token'))->createInstance([]);
|
||||
} catch (Throwable $caught) {
|
||||
$exception = $caught;
|
||||
}
|
||||
|
||||
expect($exception)->toBeInstanceOf(Exception::class)
|
||||
->and($exception)->not->toBeInstanceOf(RequestException::class)
|
||||
->and($exception->getMessage())->toBe('Vultr API error: '.$message)
|
||||
->and($exception->getCode())->toBe($status);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
})->with([
|
||||
'unauthorized' => [401, 'Unauthorized'],
|
||||
'unprocessable entity' => [422, 'Invalid instance parameters'],
|
||||
]);
|
||||
|
||||
it('maps a final Vultr rate-limit response with Retry-After', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances' => Http::sequence()
|
||||
->push(['error' => 'Temporary provider error'], 500)
|
||||
->push(['error' => 'Temporary provider error'], 500)
|
||||
->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']),
|
||||
]);
|
||||
|
||||
try {
|
||||
(new VultrService('fake-token'))->createInstance([]);
|
||||
} catch (RateLimitException $exception) {
|
||||
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
|
||||
->and($exception->retryAfter)->toBe(30);
|
||||
|
||||
Http::assertSentCount(3);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
test()->fail('Expected a RateLimitException to be thrown.');
|
||||
});
|
||||
|
||||
it('gets instances from Vultr API', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances*' => Http::response([
|
||||
|
|
|
|||
Loading…
Reference in a new issue