Create a new script to deploy and setup automated deployment

This commit is contained in:
Zura Sekhniashvili
2025-10-31 01:39:07 +04:00
parent 56b4659742
commit 48d03f1a72
8 changed files with 662 additions and 5 deletions

View File

@@ -0,0 +1,110 @@
<?php
namespace Zura\HostingerDeploy\Commands;
use Illuminate\Console\Command;
class DeployAndSetupAutomatedCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'hostinger:deploy-and-setup-automated
{--fresh : Delete and clone fresh repository}
{--site-dir= : Override site directory from config}
{--token= : GitHub API token}
{--branch= : Override default branch}
{--php-version= : Override PHP version}';
/**
* The console command description.
*/
protected $description = 'Deploy Laravel application to Hostinger and setup automated deployment via GitHub API';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('🚀 Starting complete deployment and automated setup...');
$this->line('');
// Step 1: Deploy to server
$this->info('═══════════════════════════════════════════════════════');
$this->info('Step 1: Deploying to Hostinger Server');
$this->info('═══════════════════════════════════════════════════════');
$this->line('');
$deployOptions = [];
if ($this->option('fresh')) {
$deployOptions['--fresh'] = true;
}
if ($this->option('site-dir')) {
$deployOptions['--site-dir'] = $this->option('site-dir');
}
// Call the deploy command - output will be shown in real-time
// Pass through verbosity level to ensure all output is shown
$deployOptions['-v'] = true;
$deployExitCode = $this->call('hostinger:deploy-shared', $deployOptions);
if ($deployExitCode !== self::SUCCESS) {
$this->line('');
$this->error('❌ Deployment to server failed. Cannot proceed with automated setup.');
return self::FAILURE;
}
$this->line('');
$this->info('✅ Deployment to server completed successfully!');
$this->line('');
// Step 2: Setup automated deployment
$this->info('═══════════════════════════════════════════════════════');
$this->info('Step 2: Setting up Automated Deployment');
$this->info('═══════════════════════════════════════════════════════');
$this->line('');
$setupOptions = [];
if ($this->option('token')) {
$setupOptions['--token'] = $this->option('token');
}
if ($this->option('branch')) {
$setupOptions['--branch'] = $this->option('branch');
}
if ($this->option('php-version')) {
$setupOptions['--php-version'] = $this->option('php-version');
}
// Call the setup command - output will be shown in real-time
// Pass through verbosity level to ensure all output is shown
$setupOptions['-v'] = true;
$setupExitCode = $this->call('hostinger:setup-automated-deploy', $setupOptions);
if ($setupExitCode !== self::SUCCESS) {
$this->line('');
$this->error('❌ Automated deployment setup failed.');
$this->warn('⚠️ Your application is deployed but automated deployment is not configured.');
return self::FAILURE;
}
$siteDir = $this->option('site-dir') ?: config('hostinger-deploy.deployment.site_dir');
$this->line('');
$this->info('═══════════════════════════════════════════════════════');
$this->info('🎉 Complete Setup Finished Successfully!');
$this->info('═══════════════════════════════════════════════════════');
$this->line('');
$this->info('✅ Your Laravel application is deployed and configured for automated deployment!');
$this->line('');
$this->info("🌐 Your Laravel application: https://{$siteDir}");
$this->line('');
$this->info('🚀 Next steps:');
$this->line(' 1. Push your code to trigger the GitHub Actions workflow');
$this->line(' 2. Monitor deployments in the Actions tab on GitHub');
$this->line(' 3. Your application will automatically deploy on push');
$this->line('');
return self::SUCCESS;
}
}

View File

@@ -194,9 +194,7 @@ class DeploySharedCommand extends Command
$this->line($publicKey);
$this->line('');
if (!$this->confirm('Press ENTER after adding the key to GitHub...')) {
throw new \Exception('SSH key setup cancelled');
}
$this->ask('Press ENTER after adding the key to GitHub to continue...', '');
}
} else {
$this->info('🔑 SSH keys already configured');

View File

@@ -0,0 +1,309 @@
<?php
namespace Zura\HostingerDeploy\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Zura\HostingerDeploy\Services\SshConnectionService;
use Zura\HostingerDeploy\Services\GitHubActionsService;
use Zura\HostingerDeploy\Services\GitHubAPIService;
class SetupAutomatedDeployCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'hostinger:setup-automated-deploy
{--token= : GitHub API token}
{--branch= : Override default branch}
{--php-version= : Override PHP version}';
/**
* The console command description.
*/
protected $description = 'Setup automated deployment using GitHub API (creates workflow and secrets)';
protected SshConnectionService $ssh;
protected GitHubActionsService $github;
protected ?GitHubAPIService $githubAPI = null;
public function __construct()
{
parent::__construct();
$this->github = new GitHubActionsService();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('🚀 Setting up automated deployment via GitHub API...');
// Validate configuration
if (!$this->validateConfiguration()) {
return self::FAILURE;
}
// Get repository information
$repoInfo = $this->getRepositoryInfo();
if (!$repoInfo) {
$this->error('❌ Could not detect repository information. Please run this command from a Git repository.');
return self::FAILURE;
}
$this->info("📦 Repository: {$repoInfo['owner']}/{$repoInfo['name']}");
// Initialize GitHub API
if (!$this->initializeGitHubAPI()) {
return self::FAILURE;
}
// Setup SSH connection
$this->setupSshConnection();
// Test SSH connection
if (!$this->ssh->testConnection()) {
$this->error('❌ SSH connection failed. Please check your SSH configuration.');
return self::FAILURE;
}
$this->info('✅ SSH connection successful');
// Setup SSH keys on server
if (!$this->setupSshKeys()) {
$this->error('❌ Failed to setup SSH keys');
return self::FAILURE;
}
// Get SSH information
$sshHost = config('hostinger-deploy.ssh.host');
$sshUsername = config('hostinger-deploy.ssh.username');
$sshPort = config('hostinger-deploy.ssh.port', 22);
$privateKey = $this->ssh->getPrivateKey();
if (!$privateKey) {
$this->error('❌ Could not retrieve private key from server');
return self::FAILURE;
}
// Create workflow file
if (!$this->createWorkflowFile($repoInfo)) {
return self::FAILURE;
}
// Get site directory
$siteDir = config('hostinger-deploy.deployment.site_dir');
// Create secrets (including WEBSITE_FOLDER)
if (!$this->createSecrets($repoInfo, $sshHost, $sshUsername, $sshPort, $privateKey, $siteDir)) {
return self::FAILURE;
}
$this->line('');
$this->info('🎉 Automated deployment setup completed successfully!');
$this->line('');
$this->info("🌐 Your Laravel application: https://{$siteDir}");
$this->line('');
$this->info('🚀 Your repository will now automatically deploy on push to the configured branch!');
return self::SUCCESS;
}
/**
* Validate required configuration.
*/
protected function validateConfiguration(): bool
{
$required = [
'HOSTINGER_SSH_HOST' => config('hostinger-deploy.ssh.host'),
'HOSTINGER_SSH_USERNAME' => config('hostinger-deploy.ssh.username'),
'HOSTINGER_SITE_DIR' => config('hostinger-deploy.deployment.site_dir'),
];
foreach ($required as $key => $value) {
if (empty($value)) {
$this->error("❌ Missing required environment variable: {$key}");
$this->info("Please add {$key} to your .env file");
return false;
}
}
return true;
}
/**
* Get repository information.
*/
protected function getRepositoryInfo(): ?array
{
if (!$this->github->isGitRepository()) {
return null;
}
return $this->github->getRepositoryInfo();
}
/**
* Initialize GitHub API service.
*/
protected function initializeGitHubAPI(): bool
{
try {
$token = $this->option('token') ?: env('GITHUB_API_TOKEN');
if (!$token) {
$this->error('❌ GitHub API token is required.');
$this->line('');
$this->warn('💡 Please provide your GitHub API token:');
$this->line(' 1. Set GITHUB_API_TOKEN in your .env file, or');
$this->line(' 2. Use --token=YOUR_TOKEN option');
$this->line('');
$this->info(' Create a token at: https://github.com/settings/tokens');
$this->info(' Required scopes: repo, workflow');
return false;
}
$this->githubAPI = new GitHubAPIService($token);
// Test API connection
if (!$this->githubAPI->testConnection()) {
$this->error('❌ Failed to authenticate with GitHub API. Please check your token.');
return false;
}
$this->info('✅ GitHub API connection successful');
return true;
} catch (\Exception $e) {
$this->error("❌ GitHub API error: " . $e->getMessage());
return false;
}
}
/**
* Setup SSH connection service.
*/
protected function setupSshConnection(): void
{
$this->ssh = new SshConnectionService(
config('hostinger-deploy.ssh.host'),
config('hostinger-deploy.ssh.username'),
config('hostinger-deploy.ssh.port', 22),
config('hostinger-deploy.ssh.timeout', 30)
);
}
/**
* Setup SSH keys on the server.
*/
protected function setupSshKeys(): bool
{
try {
if (!$this->ssh->sshKeyExists()) {
$this->info('🔑 Generating SSH keys on server...');
if (!$this->ssh->generateSshKey()) {
$this->error('❌ Failed to generate SSH keys');
return false;
}
} else {
$this->info('🔑 SSH keys already exist on server');
}
// Add public key to authorized_keys
$publicKey = $this->ssh->getPublicKey();
if ($publicKey && !$this->ssh->addToAuthorizedKeys($publicKey)) {
$this->warn('⚠️ Could not add public key to authorized_keys (may already exist)');
}
$this->info('✅ SSH keys setup completed');
return true;
} catch (\Exception $e) {
$this->error("SSH keys setup error: " . $e->getMessage());
return false;
}
}
/**
* Create workflow file via GitHub API.
*/
protected function createWorkflowFile(array $repoInfo): bool
{
try {
$this->info('📄 Creating GitHub Actions workflow file...');
// Get branch
$branch = $this->option('branch') ?: $this->github->getCurrentBranch() ?: config('hostinger-deploy.github.default_branch', 'main');
$phpVersion = $this->option('php-version') ?: config('hostinger-deploy.github.php_version', '8.3');
// Generate workflow content
$workflowContent = $this->generateWorkflowContent($branch, $phpVersion);
// Create or update workflow file via API
$this->githubAPI->createOrUpdateWorkflowFile(
$repoInfo['owner'],
$repoInfo['name'],
$branch,
$workflowContent
);
$this->info('✅ Workflow file created successfully');
return true;
} catch (\Exception $e) {
$this->error("❌ Failed to create workflow file: " . $e->getMessage());
return false;
}
}
/**
* Generate workflow content.
*/
protected function generateWorkflowContent(string $branch, string $phpVersion): string
{
$stubPath = __DIR__ . '/../../stubs/hostinger-deploy.yml';
if (!File::exists($stubPath)) {
throw new \Exception("Workflow stub not found: {$stubPath}");
}
$content = File::get($stubPath);
$content = str_replace('{{BRANCH}}', $branch, $content);
$content = str_replace('{{PHP_VERSION}}', $phpVersion, $content);
return $content;
}
/**
* Create secrets via GitHub API.
*/
protected function createSecrets(array $repoInfo, string $sshHost, string $sshUsername, int $sshPort, string $sshKey, string $siteDir): bool
{
try {
$this->info('🔒 Creating GitHub secrets...');
$secrets = [
'SSH_HOST' => $sshHost,
'SSH_USERNAME' => $sshUsername,
'SSH_PORT' => (string) $sshPort,
'SSH_KEY' => $sshKey,
'WEBSITE_FOLDER' => $siteDir,
];
foreach ($secrets as $name => $value) {
$this->githubAPI->createOrUpdateSecret(
$repoInfo['owner'],
$repoInfo['name'],
$name,
$value
);
$this->info("{$name} created");
}
$this->info('✅ All secrets created successfully');
return true;
} catch (\Exception $e) {
$this->error("❌ Failed to create secrets: " . $e->getMessage());
return false;
}
}
}

View File

@@ -7,6 +7,8 @@ use Zura\HostingerDeploy\Commands\DeploySharedCommand;
use Zura\HostingerDeploy\Commands\PublishWorkflowCommand;
use Zura\HostingerDeploy\Commands\AutoDeployCommand;
use Zura\HostingerDeploy\Commands\SetupEnvCommand;
use Zura\HostingerDeploy\Commands\SetupAutomatedDeployCommand;
use Zura\HostingerDeploy\Commands\DeployAndSetupAutomatedCommand;
class HostingerDeployServiceProvider extends ServiceProvider
{
@@ -31,6 +33,8 @@ class HostingerDeployServiceProvider extends ServiceProvider
PublishWorkflowCommand::class,
AutoDeployCommand::class,
SetupEnvCommand::class,
SetupAutomatedDeployCommand::class,
DeployAndSetupAutomatedCommand::class,
]);
$this->publishes([

View File

@@ -0,0 +1,199 @@
<?php
namespace Zura\HostingerDeploy\Services;
use Illuminate\Support\Facades\Http;
use Exception;
class GitHubAPIService
{
protected string $token;
protected string $baseUrl = 'https://api.github.com';
public function __construct(?string $token = null)
{
$this->token = $token ?: config('hostinger-deploy.github.api_token') ?: env('GITHUB_API_TOKEN');
if (!$this->token) {
throw new Exception('GitHub API token is required. Set GITHUB_API_TOKEN in your .env file.');
}
}
/**
* Get repository public key for encrypting secrets.
*/
public function getRepositoryPublicKey(string $owner, string $repo): array
{
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->get("{$this->baseUrl}/repos/{$owner}/{$repo}/actions/secrets/public-key");
if (!$response->successful()) {
throw new Exception("Failed to get repository public key: " . $response->body());
}
return $response->json();
}
/**
* Encrypt a secret value using LibSodium (GitHub uses NaCl Box encryption).
* Based on GitHub API documentation: https://docs.github.com/en/rest/actions/secrets
*/
public function encryptSecret(string $plaintext, string $publicKey, string $keyId): array
{
if (!extension_loaded('sodium')) {
throw new Exception('LibSodium extension is required for encrypting secrets. Install php-sodium extension.');
}
// Decode the base64 public key
$publicKeyBinary = base64_decode($publicKey, true);
if ($publicKeyBinary === false) {
throw new Exception('Failed to decode public key');
}
// GitHub uses NaCl Box sealed encryption (anonymous encryption)
// This automatically handles ephemeral key pair generation
$encrypted = sodium_crypto_box_seal($plaintext, $publicKeyBinary);
if ($encrypted === false) {
throw new Exception('Failed to encrypt secret');
}
// Encode to base64 for API
// Sealed box automatically includes ephemeral public key in the ciphertext
$encryptedValue = base64_encode($encrypted);
return [
'encrypted_value' => $encryptedValue,
'key_id' => $keyId,
];
}
/**
* Create or update a repository secret.
*/
public function createOrUpdateSecret(string $owner, string $repo, string $secretName, string $plaintextValue): bool
{
// Get public key
$publicKeyData = $this->getRepositoryPublicKey($owner, $repo);
$publicKey = $publicKeyData['key'];
$keyId = $publicKeyData['key_id'];
// Encrypt the secret
$encryptedData = $this->encryptSecret($plaintextValue, $publicKey, $keyId);
// Create or update the secret
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->put(
"{$this->baseUrl}/repos/{$owner}/{$repo}/actions/secrets/{$secretName}",
$encryptedData
);
if (!$response->successful()) {
throw new Exception("Failed to create/update secret {$secretName}: " . $response->body());
}
return true;
}
/**
* Create or update a repository variable.
*/
public function createOrUpdateVariable(string $owner, string $repo, string $variableName, string $value): bool
{
// Check if variable exists first
$existingResponse = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->get("{$this->baseUrl}/repos/{$owner}/{$repo}/actions/variables/{$variableName}");
$method = $existingResponse->successful() ? 'PATCH' : 'POST';
$url = "{$this->baseUrl}/repos/{$owner}/{$repo}/actions/variables" .
($method === 'PATCH' ? "/{$variableName}" : '');
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->{strtolower($method)}($url, [
'name' => $variableName,
'value' => $value,
]);
if (!$response->successful()) {
throw new Exception("Failed to create/update variable {$variableName}: " . $response->body());
}
return true;
}
/**
* Create or update a workflow file.
*/
public function createOrUpdateWorkflowFile(string $owner, string $repo, string $branch, string $workflowContent): bool
{
$filePath = '.github/workflows/hostinger-deploy.yml';
// Check if file exists
$existingResponse = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->get("{$this->baseUrl}/repos/{$owner}/{$repo}/contents/{$filePath}");
$sha = null;
if ($existingResponse->successful()) {
$sha = $existingResponse->json()['sha'];
}
$data = [
'message' => $sha ? 'Update Hostinger deployment workflow' : 'Add Hostinger deployment workflow',
'content' => base64_encode($workflowContent),
'branch' => $branch,
];
if ($sha) {
$data['sha'] = $sha;
}
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->put(
"{$this->baseUrl}/repos/{$owner}/{$repo}/contents/{$filePath}",
$data
);
if (!$response->successful()) {
throw new Exception("Failed to create/update workflow file: " . $response->body());
}
return true;
}
/**
* Test API connection.
*/
public function testConnection(): bool
{
try {
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer {$this->token}",
'X-GitHub-Api-Version' => '2022-11-28',
])->get("{$this->baseUrl}/user");
return $response->successful();
} catch (Exception $e) {
return false;
}
}
}

View File

@@ -101,11 +101,20 @@ class SshConnectionService
}
/**
* Add a public key to authorized_keys.
* Add a public key to authorized_keys if it doesn't already exist.
*/
public function addToAuthorizedKeys(string $publicKey): bool
{
try {
// Check if the key already exists in authorized_keys
$keyExists = $this->keyExistsInAuthorizedKeys($publicKey);
if ($keyExists) {
// Key already exists, don't add it again
return true;
}
// Key doesn't exist, add it
$this->execute("echo '{$publicKey}' >> ~/.ssh/authorized_keys");
return true;
} catch (\Exception $e) {
@@ -113,6 +122,33 @@ class SshConnectionService
}
}
/**
* Check if a public key already exists in authorized_keys.
*/
public function keyExistsInAuthorizedKeys(string $publicKey): bool
{
try {
// Extract the key part (without comment) for comparison
$keyParts = explode(' ', trim($publicKey));
if (count($keyParts) < 2) {
return false;
}
$keyData = $keyParts[1]; // The actual key data (middle part)
// Check if this key data exists in authorized_keys
// Use grep with escaped key data to avoid special character issues
$escapedKeyData = escapeshellarg($keyData);
$command = "grep -Fq {$escapedKeyData} ~/.ssh/authorized_keys 2>/dev/null && echo 'exists' || echo 'not_exists'";
$result = trim($this->execute($command));
return $result === 'exists';
} catch (\Exception $e) {
// If we can't check, assume it doesn't exist
return false;
}
}
/**
* Check if SSH key exists on the server.
*/

View File

@@ -47,6 +47,7 @@ return [
'workflow_file' => '.github/workflows/hostinger-deploy.yml',
'php_version' => '8.3',
'default_branch' => 'main',
'api_token' => env('GITHUB_API_TOKEN'),
],
/*

View File

@@ -41,7 +41,7 @@ jobs:
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_KEY }}
script: |
cd domains/${{ vars.WEBSITE_FOLDER }}
cd domains/${{ secrets.WEBSITE_FOLDER }}
git checkout {{BRANCH}}
git pull
composer install --no-dev --optimize-autoloader