fix(deploy): improve deployment robustness and reliability
- Add docker volume prune to deploy.sh to prevent stale code issues - Add automatic migrations and cache warmup to staging entrypoint - Fix nginx race condition by waiting for PHP-FPM before starting - Improve PHP healthcheck to use php-fpm-healthcheck - Add curl to production nginx Dockerfile for healthchecks - Add ensureSeedsTable() to SeedRepository for automatic table creation - Update SeedCommand to ensure seeds table exists before operations This prevents 502 Bad Gateway errors during deployment and ensures fresh code is deployed without volume cache issues.
This commit is contained in:
@@ -121,6 +121,13 @@ docker compose $COMPOSE_FILES pull || print_warning "Failed to pull some images,
|
|||||||
print_info "Stopping existing containers..."
|
print_info "Stopping existing containers..."
|
||||||
docker compose $COMPOSE_FILES down --remove-orphans || print_warning "No existing containers to stop"
|
docker compose $COMPOSE_FILES down --remove-orphans || print_warning "No existing containers to stop"
|
||||||
|
|
||||||
|
# Staging: Remove named volume to ensure fresh code from image
|
||||||
|
# This prevents stale code persisting between deployments
|
||||||
|
if [ "$ENVIRONMENT" = "staging" ]; then
|
||||||
|
print_info "Removing staging code volume to ensure fresh deployment..."
|
||||||
|
docker volume rm staging-code 2>/dev/null || print_info "No stale staging volume to remove"
|
||||||
|
fi
|
||||||
|
|
||||||
# Remove any orphaned containers with conflicting names
|
# Remove any orphaned containers with conflicting names
|
||||||
for container in nginx php redis scheduler queue-worker; do
|
for container in nginx php redis scheduler queue-worker; do
|
||||||
if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
|
if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
|
||||||
|
|||||||
@@ -147,9 +147,10 @@ services:
|
|||||||
- app-backend
|
- app-backend
|
||||||
- app-internal
|
- app-internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "php", "-v"]
|
# Check if PHP-FPM is accepting connections
|
||||||
interval: 30s
|
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
|
||||||
timeout: 10s
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -175,6 +175,20 @@ services:
|
|||||||
echo "📊 Environment variables:"
|
echo "📊 Environment variables:"
|
||||||
env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true
|
env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
if [ -f /var/www/html/console.php ]; then
|
||||||
|
echo ""
|
||||||
|
echo "🗄️ Running database migrations..."
|
||||||
|
cd /var/www/html
|
||||||
|
php console.php db:migrate --force || echo "⚠️ Migration warning (may be OK if already migrated)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warm up caches
|
||||||
|
echo ""
|
||||||
|
echo "🔥 Warming up caches..."
|
||||||
|
cd /var/www/html
|
||||||
|
php console.php cache:warm 2>/dev/null || echo "ℹ️ Cache warmup skipped (command may not exist)"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🛠️ Adjusting filesystem permissions..."
|
echo "🛠️ Adjusting filesystem permissions..."
|
||||||
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
|
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
|
||||||
@@ -191,11 +205,13 @@ services:
|
|||||||
echo "REDIS_PASSWORD_FILE: ${REDIS_PASSWORD_FILE:-NOT SET}"
|
echo "REDIS_PASSWORD_FILE: ${REDIS_PASSWORD_FILE:-NOT SET}"
|
||||||
exec php-fpm
|
exec php-fpm
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "php-fpm-healthcheck || true"]
|
# Use HTTP liveness check via php-fpm (not via nginx)
|
||||||
interval: 30s
|
# This checks if the PHP application is actually responding
|
||||||
timeout: 10s
|
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 60s
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -265,6 +281,26 @@ services:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Wait for PHP-FPM to be ready before starting nginx
|
||||||
|
# This prevents 502 Bad Gateway errors during startup
|
||||||
|
echo "⏳ [staging-nginx] Waiting for PHP-FPM to be ready..."
|
||||||
|
MAX_WAIT=30
|
||||||
|
WAITED=0
|
||||||
|
while [ $$WAITED -lt $$MAX_WAIT ]; do
|
||||||
|
# Check if PHP-FPM is accepting connections on port 9000
|
||||||
|
if nc -z php 9000 2>/dev/null; then
|
||||||
|
echo "✅ [staging-nginx] PHP-FPM is ready on php:9000"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " [staging-nginx] PHP-FPM not ready yet... ($$WAITED/$$MAX_WAIT)"
|
||||||
|
sleep 1
|
||||||
|
WAITED=$$((WAITED + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $$WAITED -ge $$MAX_WAIT ]; then
|
||||||
|
echo "⚠️ [staging-nginx] PHP-FPM did not become ready within $$MAX_WAIT seconds, starting anyway..."
|
||||||
|
fi
|
||||||
|
|
||||||
# Start nginx only (no PHP-FPM, no Git clone - php container handles that)
|
# Start nginx only (no PHP-FPM, no Git clone - php container handles that)
|
||||||
echo "🚀 [staging-nginx] Starting nginx..."
|
echo "🚀 [staging-nginx] Starting nginx..."
|
||||||
exec nginx -g "daemon off;"
|
exec nginx -g "daemon off;"
|
||||||
@@ -280,11 +316,12 @@ services:
|
|||||||
# Network
|
# Network
|
||||||
- "traefik.docker.network=traefik-public"
|
- "traefik.docker.network=traefik-public"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://127.0.0.1/health || exit 1"]
|
# Use /health/live endpoint for lightweight liveness check
|
||||||
interval: 30s
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1/health/live || exit 1"]
|
||||||
timeout: 10s
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
depends_on:
|
depends_on:
|
||||||
php:
|
php:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ RUN apk add --no-cache \
|
|||||||
certbot-nginx \
|
certbot-nginx \
|
||||||
su-exec \
|
su-exec \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
bash
|
bash
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,3 @@ final readonly class CreateSeedsTable implements Migration, SafelyReversible
|
|||||||
return 'Create seeds table for tracking executed seeders';
|
return 'Create seeds table for tracking executed seeders';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ final readonly class SeedCommand
|
|||||||
$fresh = $input->hasOption('fresh');
|
$fresh = $input->hasOption('fresh');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure seeds table exists (auto-create if missing)
|
||||||
|
$this->seedRepository->ensureSeedsTable();
|
||||||
|
|
||||||
// Clear seeds table if --fresh option is provided
|
// Clear seeds table if --fresh option is provided
|
||||||
if ($fresh) {
|
if ($fresh) {
|
||||||
echo "⚠️ Clearing seeds table (--fresh option)...\n";
|
echo "⚠️ Clearing seeds table (--fresh option)...\n";
|
||||||
@@ -38,6 +41,7 @@ final readonly class SeedCommand
|
|||||||
|
|
||||||
if ($seeder === null) {
|
if ($seeder === null) {
|
||||||
echo "❌ Seeder '{$className}' not found or cannot be instantiated.\n";
|
echo "❌ Seeder '{$className}' not found or cannot be instantiated.\n";
|
||||||
|
|
||||||
return ExitCode::SOFTWARE_ERROR;
|
return ExitCode::SOFTWARE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +54,7 @@ final readonly class SeedCommand
|
|||||||
|
|
||||||
if (empty($seeders)) {
|
if (empty($seeders)) {
|
||||||
echo "No seeders found.\n";
|
echo "No seeders found.\n";
|
||||||
|
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,4 +79,3 @@ final readonly class SeedCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,4 +61,3 @@ final readonly class SeedLoader
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,68 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\Database\Seed;
|
namespace App\Framework\Database\Seed;
|
||||||
|
|
||||||
use App\Framework\Database\ConnectionInterface;
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Database\Platform\DatabasePlatform;
|
||||||
use App\Framework\Database\ValueObjects\SqlQuery;
|
use App\Framework\Database\ValueObjects\SqlQuery;
|
||||||
use App\Framework\DateTime\Clock;
|
use App\Framework\DateTime\Clock;
|
||||||
|
|
||||||
final readonly class SeedRepository
|
final readonly class SeedRepository
|
||||||
{
|
{
|
||||||
|
private const string TABLE_NAME = 'seeds';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConnectionInterface $connection,
|
private ConnectionInterface $connection,
|
||||||
|
private DatabasePlatform $platform,
|
||||||
private Clock $clock
|
private Clock $clock
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the seeds table exists, create it if not
|
||||||
|
*
|
||||||
|
* This method should be called before any seed operations to prevent
|
||||||
|
* "table does not exist" errors on fresh databases.
|
||||||
|
*/
|
||||||
|
public function ensureSeedsTable(): void
|
||||||
|
{
|
||||||
|
if ($this->tableExists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createSeedsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the seeds table exists
|
||||||
|
*/
|
||||||
|
private function tableExists(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$sql = $this->platform->getTableExistsSQL(self::TABLE_NAME);
|
||||||
|
$result = $this->connection->queryScalar(SqlQuery::create($sql));
|
||||||
|
|
||||||
|
return (bool) $result;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the seeds tracking table
|
||||||
|
*/
|
||||||
|
private function createSeedsTable(): void
|
||||||
|
{
|
||||||
|
$sql = <<<'SQL'
|
||||||
|
CREATE TABLE seeds (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$this->connection->execute(SqlQuery::create($sql));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a seeder has already been executed
|
* Check if a seeder has already been executed
|
||||||
*/
|
*/
|
||||||
@@ -60,4 +111,3 @@ final readonly class SeedRepository
|
|||||||
return hash('sha256', $name);
|
return hash('sha256', $name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final readonly class SeedRunner
|
|||||||
// Check if already executed
|
// Check if already executed
|
||||||
if ($this->seedRepository->hasRun($name)) {
|
if ($this->seedRepository->hasRun($name)) {
|
||||||
$this->log("Skipping seeder '{$name}' - already executed", 'info');
|
$this->log("Skipping seeder '{$name}' - already executed", 'info');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ final readonly class SeedRunner
|
|||||||
$this->log("Seeder '{$name}' completed successfully", 'info');
|
$this->log("Seeder '{$name}' completed successfully", 'info');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->log("Seeder '{$name}' failed: {$e->getMessage()}", 'error');
|
$this->log("Seeder '{$name}' failed: {$e->getMessage()}", 'error');
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,4 +73,3 @@ final readonly class SeedRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\Database\Seed;
|
namespace App\Framework\Database\Seed;
|
||||||
|
|
||||||
use App\Framework\Database\ConnectionInterface;
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Database\Platform\DatabasePlatform;
|
||||||
use App\Framework\DateTime\Clock;
|
use App\Framework\DateTime\Clock;
|
||||||
use App\Framework\DI\Container;
|
use App\Framework\DI\Container;
|
||||||
use App\Framework\DI\Initializer;
|
use App\Framework\DI\Initializer;
|
||||||
@@ -20,6 +21,7 @@ final readonly class SeedServicesInitializer
|
|||||||
$container->singleton(SeedRepository::class, function (Container $c) {
|
$container->singleton(SeedRepository::class, function (Container $c) {
|
||||||
return new SeedRepository(
|
return new SeedRepository(
|
||||||
$c->get(ConnectionInterface::class),
|
$c->get(ConnectionInterface::class),
|
||||||
|
$c->get(DatabasePlatform::class),
|
||||||
$c->get(Clock::class)
|
$c->get(Clock::class)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -27,12 +29,14 @@ final readonly class SeedServicesInitializer
|
|||||||
// SeedLoader
|
// SeedLoader
|
||||||
$container->singleton(SeedLoader::class, function (Container $c) {
|
$container->singleton(SeedLoader::class, function (Container $c) {
|
||||||
$discoveryRegistry = $c->get(DiscoveryRegistry::class);
|
$discoveryRegistry = $c->get(DiscoveryRegistry::class);
|
||||||
|
|
||||||
return new SeedLoader($discoveryRegistry, $c);
|
return new SeedLoader($discoveryRegistry, $c);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SeedRunner
|
// SeedRunner
|
||||||
$container->singleton(SeedRunner::class, function (Container $c) {
|
$container->singleton(SeedRunner::class, function (Container $c) {
|
||||||
$logger = $c->has(Logger::class) ? $c->get(Logger::class) : null;
|
$logger = $c->has(Logger::class) ? $c->get(Logger::class) : null;
|
||||||
|
|
||||||
return new SeedRunner(
|
return new SeedRunner(
|
||||||
$c->get(SeedRepository::class),
|
$c->get(SeedRepository::class),
|
||||||
$logger
|
$logger
|
||||||
@@ -40,4 +44,3 @@ final readonly class SeedServicesInitializer
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,4 +36,3 @@ interface Seeder
|
|||||||
*/
|
*/
|
||||||
public function getDescription(): string;
|
public function getDescription(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user