Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Options for importing database #107

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions app/Commands/ProvisionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
use App\Services\Forge\Pipeline\EnsureJobScheduled;
use App\Services\Forge\Pipeline\FindServer;
use App\Services\Forge\Pipeline\FindSite;
use App\Services\Forge\Pipeline\ImportDatabaseFromSql;
use App\Services\Forge\Pipeline\InstallGitRepository;
use App\Services\Forge\Pipeline\NginxTemplateSearchReplace;
use App\Services\Forge\Pipeline\ObtainLetsEncryptCertification;
use App\Services\Forge\Pipeline\OrCreateNewSite;
use App\Services\Forge\Pipeline\PutCommentOnPullRequest;
use App\Services\Forge\Pipeline\RunOptionalCommands;
use App\Services\Forge\Pipeline\SeedDatabase;
use App\Services\Forge\Pipeline\UpdateDeployScript;
use App\Services\Forge\Pipeline\UpdateEnvironmentVariables;
use App\Traits\Outputifier;
Expand Down Expand Up @@ -56,7 +58,9 @@ public function handle(ForgeService $service): void
EnableQuickDeploy::class,
UpdateEnvironmentVariables::class,
UpdateDeployScript::class,
ImportDatabaseFromSql::class,
DeploySite::class,
SeedDatabase::class,
RunOptionalCommands::class,
EnsureJobScheduled::class,
PutCommentOnPullRequest::class,
Expand Down
16 changes: 16 additions & 0 deletions app/Rules/DBSeed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class DBSeed implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) && !is_bool($value)) {
$fail('The DB seed value must be a string or boolean.');
}
}
}
7 changes: 7 additions & 0 deletions app/Services/Forge/ForgeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Laravel\Forge\Forge;
use Laravel\Forge\Resources\Server;
use Laravel\Forge\Resources\Site;
use Laravel\Forge\Resources\SiteCommand;

class ForgeService
{
Expand Down Expand Up @@ -149,4 +150,10 @@ public function siteDirectory(): string
{
return sprintf('/home/%s/%s', $this->site->username, $this->site->name);
}

public function waitForSiteCommand(SiteCommand $site_command): SiteCommand
{
$waiter = app()->makeWith(ForgeSiteCommandWaiter::class, ['forge' => $this->forge]);
return $waiter->waitFor($site_command);
}
}
21 changes: 20 additions & 1 deletion app/Services/Forge/ForgeSetting.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace App\Services\Forge;

use App\Rules\BranchNameRegex;
use App\Rules\DBSeed;
use App\Traits\Outputifier;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -123,6 +124,21 @@ class ForgeSetting
*/
public ?string $dbName;

/**
* Flag / seeder to seed database.
*/
public bool|string $dbSeed;

/**
* Path of file to import into database.
*/
public ?string $dbImportSql;

/**
* Flag to import database on deployment.
*/
public bool $dbImportOnDeployment;

/**
* Flag to auto-source environment variables in deployment.
*/
Expand Down Expand Up @@ -215,7 +231,7 @@ protected function validate(array $configurations): \Illuminate\Validation\Valid
'domain' => ['required'],
'git_provider' => ['required'],
'repository' => ['required'],
'branch' => ['required', new BranchNameRegex],
'branch' => ['required', new BranchNameRegex()],
'project_type' => ['string'],
'php_version' => ['nullable', 'string'],
'subdomain_pattern' => ['nullable', 'string'],
Expand All @@ -226,6 +242,9 @@ protected function validate(array $configurations): \Illuminate\Validation\Valid
'job_scheduler_required' => ['boolean'],
'db_creation_required' => ['boolean'],
'db_name' => ['nullable', 'string'],
'db_import_sql' => ['nullable', 'string'],
'db_import_seed' => [new DBSeed()],
'db_import_on_deployment' => ['boolean'],
'auto_source_required' => ['boolean'],
'ssl_required' => ['boolean'],
'wait_on_ssl' => ['boolean'],
Expand Down
67 changes: 67 additions & 0 deletions app/Services/Forge/ForgeSiteCommandWaiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

/**
* This file is part of Laravel Harbor.
*
* (c) Mehran Rasulian <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Services\Forge;

use Laravel\Forge\Forge;
use Laravel\Forge\Resources\SiteCommand;
use Illuminate\Support\Sleep;

class ForgeSiteCommandWaiter
{
/**
* The number of seconds to wait between querying Forge for the command status.
*/
public int $retrySeconds = 10;

/**
* The number of attempts to make before returning the command.
*/
public int $maxAttempts = 60;

/**
* The current number of attempts.
*/
protected int $attempts = 0;

public function __construct(public Forge $forge)
{
}

public function waitFor(SiteCommand $site_command): SiteCommand
{
$this->attempts = 0;

while (
$this->commandIsRunning($site_command)
&& $this->attempts++ < $this->maxAttempts
) {
Sleep::for($this->retrySeconds)->seconds();

$site_command = $this->forge->getSiteCommand(
$site_command->serverId,
$site_command->siteId,
$site_command->id
)[0];
}

return $site_command;
}

protected function commandIsRunning(SiteCommand $site_command): bool
{
return !isset($site_command->status)
|| in_array($site_command->status, ['running', 'waiting']);
}

}
106 changes: 106 additions & 0 deletions app/Services/Forge/Pipeline/ImportDatabaseFromSql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

/**
* This file is part of Laravel Harbor.
*
* (c) Mehran Rasulian <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Services\Forge\Pipeline;

use App\Services\Forge\ForgeService;
use App\Traits\Outputifier;
use Closure;

class ImportDatabaseFromSql
{
use Outputifier;

public function __invoke(ForgeService $service, Closure $next)
{
if (!($file = $service->setting->dbImportSql)) {
return $next($service);
}

if (!$service->siteNewlyMade && !$service->setting->dbImportOnDeployment) {
return $next($service);
}

return $this->attemptImport($service, $next, $file);
}

public function attemptImport(ForgeService $service, Closure $next, string $file)
{
$this->information(sprintf('Importing database from %s.', $file));

$content = $this->buildImportCommandContent($service, $file);

$site_command = $service->waitForSiteCommand(
$service->forge->executeSiteCommand(
$service->setting->server,
$service->site->id,
['command' => $content]
)
);

if ($site_command->status === 'failed') {
$this->fail(sprintf('---> Database import failed with message: %s', $site_command->output));
return $next;

} elseif ($site_command->status !== 'finished') {
$this->fail('---> Database import did not finish in time.');
return $next;
}

return $next($service);
}

public function buildImportCommandContent(ForgeService $service, string $file): string
{
$extract = match(pathinfo($file, PATHINFO_EXTENSION)) {
'gz' => "gunzip < {$file}",
'zip' => "unzip -p {$file}",
default => "cat {$file}"
};

return implode(' ', [
$extract,
'|',
$this->buildDatabaseConnection($service)
]);
}

protected function buildDatabaseConnection(ForgeService $service): string
{
if (str_contains($service->server->databaseType, 'postgres')) {
return sprintf(
'pgsql postgres://%s:%s%s/%s',
$service->database['DB_USERNAME'],
$service->database['DB_PASSWORD'],
Comment on lines +83 to +84
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database property from $service only gets filled on the first run. One option is to refetch the env keys to get the credentials. The TextToArray action class might be useful here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. I’ll try to resolve that soon!

On a related note, I discovered that Forge only waits for 2 minutes before marking a command as failed. So if this feature gets merged, we should note that this may not work in larger datasets that take longer to import.

isset($service->database['DB_HOST'])
? sprintf(
'@%s:%s',
$service->database['DB_HOST'],
$service->database['DB_PORT'] ?? '5432',
)
: '',
$service->getFormattedDatabaseName(),
);
}

return sprintf(
'%s -u %s -p%s %s %s %s',
str_contains($service->server->databaseType, 'mariadb') ? 'mariadb' : 'mysql',
$service->database['DB_USERNAME'],
$service->database['DB_PASSWORD'],
isset($service->database['DB_HOST']) ? '-h ' . $service->database['DB_HOST'] : '',
isset($service->database['DB_PORT']) ? '-P ' . $service->database['DB_PORT'] : '',
$service->getFormattedDatabaseName(),
);
}
}
89 changes: 89 additions & 0 deletions app/Services/Forge/Pipeline/SeedDatabase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

/**
* This file is part of Laravel Harbor.
*
* (c) Mehran Rasulian <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Services\Forge\Pipeline;

use App\Services\Forge\ForgeService;
use App\Traits\Outputifier;
use Closure;

class SeedDatabase
{
use Outputifier;

public function __invoke(ForgeService $service, Closure $next)
{
if (!($seeder = $service->setting->dbSeed)) {
return $next($service);
}

if (!$service->siteNewlyMade) {
return $next($service);
}

return $this->attemptSeed($service, $next, $seeder);
}

public function attemptSeed(ForgeService $service, Closure $next)
{
$this->information(sprintf('Seeding database.'));

$content = $this->buildImportCommandContent($service);

$site_command = $service->waitForSiteCommand(
$service->forge->executeSiteCommand(
$service->setting->server,
$service->site->id,
['command' => $content]
)
);

if ($site_command->status === 'failed') {
$this->fail(sprintf('---> Database seed failed with message: %s', $site_command->output));
return $next;

} elseif ($site_command->status !== 'finished') {
$this->fail('---> Database seed did not finish in time.');
return $next;
}

return $next($service);
}

public function buildImportCommandContent(ForgeService $service): string
{
$seeder = '';
if (is_string($service->setting->dbSeed)) {
$seeder = sprintf(
'--class=%s',
$service->setting->dbSeed
);
}

return trim(sprintf(
'%s artisan db:seed %s',
$this->phpExecutable($service->site->phpVersion ?? 'php'),
$seeder
));
}

/**
* Forge's phpVersion strings don't exactly map to the executable.
* For example php83 corresponds to the php8.3 executable.
* This workaround assumes no minor versions above 9!
*/
protected function phpExecutable(string $phpVersion): string
{
return preg_replace_callback('/\d$/', fn ($matches) => '.' . $matches[0], $phpVersion);
}
}
9 changes: 9 additions & 0 deletions config/forge.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@
// Override default database and database username, if needed. Defaults to the site name.
'db_name' => env('FORGE_DB_NAME', null),

// Seed the database (default: false).
'db_seed' => env('FORGE_DB_SEED', false),

// Import the database via a SQL file (default: null).
'db_import_sql' => env('FORGE_DB_IMPORT_SQL', null),

// Flag to perform database import on deployment (default: false).
'db_import_on_deployment' => env('FORGE_DB_IMPORT_ON_DEPLOYMENT', false),

// Flag to enable SSL certification (default: false).
'ssl_required' => env('FORGE_SSL_REQUIRED', false),

Expand Down
Loading
Loading