diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml new file mode 100644 index 0000000..518cba4 --- /dev/null +++ b/.github/workflows/docker-test.yml @@ -0,0 +1,131 @@ +name: Docker Deployment Tests + +on: + push: + branches: [ main, develop ] + paths: + - 'docker/**' + - 'Dockerfile' + - '.env.example' + - 'docker-compose.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'docker/**' + - 'Dockerfile' + - '.env.example' + - 'docker-compose.yml' + +jobs: + docker-config-tests: + name: Docker Configuration Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, dom, filter, gd, json, zip, yaml + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run Docker Configuration Tests + run: php artisan test tests/Unit/DockerConfigurationTest.php --verbose + + docker-build-test: + name: Docker Build Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Test Docker Build + run: | + docker build -f docker/Dockerfile -t nullfake-test . + docker images nullfake-test + + - name: Test Docker Compose Configuration + run: | + cp .env.example .env + docker-compose -f docker/docker-compose.yml config + + docker-integration-test: + name: Docker Integration Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Create environment file + run: | + cp .env.example .env + # Set minimal configuration for testing + echo "OPENAI_API_KEY=test_key" >> .env + echo "LLM_PRIMARY_PROVIDER=ollama" >> .env + + - name: Start Docker services + run: | + docker-compose -f docker/docker-compose.yml up -d + + - name: Wait for services to be ready + run: | + echo "Waiting for services to start..." + sleep 30 + + - name: Test service health + run: | + # Test database + docker-compose -f docker/docker-compose.yml exec -T db mysql -u faker -ppassword -e "SELECT 1;" + + # Test web server + curl -f http://localhost:8080 || exit 1 + + # Test Ollama service + curl -f http://localhost:11434/api/tags || exit 1 + + - name: Check container logs + if: failure() + run: | + echo "=== App Logs ===" + docker-compose -f docker/docker-compose.yml logs app + echo "=== Nginx Logs ===" + docker-compose -f docker/docker-compose.yml logs nginx + echo "=== Database Logs ===" + docker-compose -f docker/docker-compose.yml logs db + echo "=== Queue Logs ===" + docker-compose -f docker/docker-compose.yml logs queue + + - name: Cleanup + if: always() + run: | + docker-compose -f docker/docker-compose.yml down -v + + docker-security-scan: + name: Docker Security Scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -f docker/Dockerfile -t nullfake-security-test . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: 'nullfake-security-test' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v2 + if: always() + with: + sarif_file: 'trivy-results.sarif' diff --git a/docker.env.example b/docker.env.example deleted file mode 100644 index dc053eb..0000000 --- a/docker.env.example +++ /dev/null @@ -1,85 +0,0 @@ -# Docker Environment Configuration for NullFake -# This file contains SAFE defaults for Docker deployment -# Copy this to .env in the project root and customize with your API keys - -# Laravel Application Configuration -APP_NAME="NullFake" -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_URL=http://localhost:8080 - -# Database Configuration (Docker) -DB_CONNECTION=mysql -DB_HOST=db -DB_PORT=3306 -DB_DATABASE=faker -DB_USERNAME=faker -DB_PASSWORD=password - -# Cache Configuration (File-based for Docker) -CACHE_DRIVER=file -SESSION_DRIVER=file -SESSION_LIFETIME=120 - -# Queue Configuration -QUEUE_CONNECTION=database -ANALYSIS_ASYNC_ENABLED=true - -# Mail Configuration -MAIL_MAILER=smtp -MAIL_HOST=mailpit -MAIL_PORT=1025 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -# Docker Sail Configuration -WWWGROUP=1000 -WWWUSER=1000 -SAIL_XDEBUG_MODE=develop,debug -SAIL_XDEBUG_CONFIG="client_host=host.docker.internal" - -# Port Configuration -APP_PORT=8080 -FORWARD_DB_PORT=3307 -FORWARD_OLLAMA_PORT=11434 -VITE_PORT=5173 - -# External Services Configuration -# OpenAI (Optional - for AI analysis) -OPENAI_API_KEY=your_openai_api_key_here -OPENAI_MODEL=gpt-4o-mini - -# DeepSeek (Optional - alternative AI provider) -DEEPSEEK_API_KEY=your_deepseek_api_key_here - -# Ollama (Local AI - runs in Docker) -OLLAMA_BASE_URL=http://ollama:11434 -OLLAMA_MODEL=phi4:14b - -# BrightData (Optional - for production scraping) -BRIGHTDATA_API_KEY=your_brightdata_api_key_here - -# Amazon Configuration -AMAZON_REVIEW_SERVICE=scraping -AMAZON_AFFILIATE_TAG_US=your_affiliate_tag_here - -# LLM Configuration -LLM_PRIMARY_PROVIDER=ollama -LLM_AUTO_FALLBACK=true - -# Analysis Configuration -ANALYSIS_ASYNC_ENABLED=true -ANALYSIS_QUEUE_CONNECTION=database - -# Monitoring (Optional) -PUSHOVER_USER_KEY=your_pushover_user_key_here -PUSHOVER_API_TOKEN=your_pushover_api_token_here - -# Newsletter (Optional) -MAILTRAIN_BASE_URL=your_mailtrain_url_here -MAILTRAIN_API_TOKEN=your_mailtrain_token_here -MAILTRAIN_LIST_ID=your_list_id_here diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1c6d5ff..21b098b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,9 +2,9 @@ # This is an OPTIONAL deployment method - your local development environment remains unchanged # # To use Docker: -# 1. Copy docker.env.example to .env in the project root -# 2. Run: docker-compose -f docker/docker-compose.yml up -d -# 3. Run: docker-compose -f docker/docker-compose.yml exec app php artisan migrate +# 1. Copy .env.example to .env in the project root +# 2. Edit .env with your API keys (at minimum one LLM provider) +# 3. Run: docker-compose -f docker/docker-compose.yml up -d # # To stop: docker-compose -f docker/docker-compose.yml down @@ -18,6 +18,7 @@ services: container_name: nullfake-app restart: unless-stopped working_dir: /var/www/html + env_file: ../.env volumes: - ../:/var/www/html - vendor_data:/var/www/html/vendor @@ -68,6 +69,7 @@ services: container_name: nullfake-queue restart: unless-stopped working_dir: /var/www/html + env_file: ../.env volumes: - ../:/var/www/html - vendor_data:/var/www/html/vendor diff --git a/tests/Feature/DockerDeploymentTest.php b/tests/Feature/DockerDeploymentTest.php new file mode 100644 index 0000000..822a1aa --- /dev/null +++ b/tests/Feature/DockerDeploymentTest.php @@ -0,0 +1,279 @@ +isDockerAvailable()) { + $this->markTestSkipped('Docker is not available'); + } + } + + #[Test] + public function docker_compose_configuration_is_valid() + { + $result = Process::run('docker-compose -f docker/docker-compose.yml config'); + + $this->assertTrue($result->successful(), 'Docker Compose configuration should be valid'); + $this->assertStringContains('services:', $result->output()); + $this->assertStringContains('nullfake-app', $result->output()); + $this->assertStringContains('nullfake-db', $result->output()); + $this->assertStringContains('nullfake-nginx', $result->output()); + } + + #[Test] + public function env_example_contains_required_docker_variables() + { + $envExample = file_get_contents(base_path('.env.example')); + + // Critical Docker-related variables + $requiredVars = [ + 'DB_HOST=', + 'DB_DATABASE=', + 'QUEUE_CONNECTION=', + 'ANALYSIS_ASYNC_ENABLED=', + 'LLM_PRIMARY_PROVIDER=', + 'OLLAMA_BASE_URL=', + 'OPENAI_API_KEY=', + 'AMAZON_REVIEW_SERVICE=', + ]; + + foreach ($requiredVars as $var) { + $this->assertStringContains($var, $envExample, "Missing required variable: {$var}"); + } + } + + #[Test] + public function dockerfile_builds_successfully() + { + // Test that Dockerfile can build without errors + $result = Process::timeout(300)->run('docker build -f docker/Dockerfile -t nullfake-test .'); + + $this->assertTrue($result->successful(), 'Dockerfile should build successfully'); + + // Cleanup test image + Process::run('docker rmi nullfake-test'); + } + + #[Test] + public function docker_entrypoint_script_is_executable() + { + $entrypointPath = base_path('docker/entrypoint.sh'); + + $this->assertFileExists($entrypointPath); + $this->assertTrue(is_executable($entrypointPath), 'Entrypoint script should be executable'); + } + + #[Test] + public function nginx_configuration_is_valid() + { + $nginxConfig = file_get_contents(base_path('docker/nginx/default.conf')); + + // Check for essential nginx directives + $this->assertStringContains('server {', $nginxConfig); + $this->assertStringContains('listen 80;', $nginxConfig); + $this->assertStringContains('root /var/www/html/public;', $nginxConfig); + $this->assertStringContains('fastcgi_pass app:9000;', $nginxConfig); + $this->assertStringContains('index.php', $nginxConfig); + } + + #[Test] + public function php_configuration_has_required_settings() + { + $phpConfig = file_get_contents(base_path('docker/php/local.ini')); + + // Check for essential PHP settings + $this->assertStringContains('memory_limit=512M', $phpConfig); + $this->assertStringContains('upload_max_filesize=100M', $phpConfig); + $this->assertStringContains('post_max_size=100M', $phpConfig); + $this->assertStringContains('max_execution_time=300', $phpConfig); + } + + /** + * Integration test that starts containers and validates functionality + * This is a comprehensive test that requires Docker to be running + * + * @group docker + * @group slow + */ + #[Test] + public function full_docker_stack_starts_and_responds() + { + if (!$this->isDockerAvailable()) { + $this->markTestSkipped('Docker integration test requires Docker to be running'); + } + + // Ensure we have a .env file for testing + if (!file_exists(base_path('.env'))) { + copy(base_path('.env.example'), base_path('.env')); + } + + try { + // Start containers + $startResult = Process::timeout(120)->run('docker-compose -f docker/docker-compose.yml up -d'); + $this->assertTrue($startResult->successful(), 'Docker containers should start successfully'); + + // Wait for services to be ready + sleep(30); + + // Test database connectivity + $dbTest = Process::run('docker-compose -f docker/docker-compose.yml exec -T db mysql -u faker -ppassword -e "SELECT 1;"'); + $this->assertTrue($dbTest->successful(), 'Database should be accessible'); + + // Test web server response + $webTest = Process::run('curl -s -o /dev/null -w "%{http_code}" http://localhost:8080'); + $this->assertEquals('200', trim($webTest->output()), 'Web server should respond with HTTP 200'); + + // Test Ollama service + $ollamaTest = Process::run('curl -s http://localhost:11434/api/tags'); + $this->assertTrue($ollamaTest->successful(), 'Ollama service should be accessible'); + + } finally { + // Cleanup: Stop containers + Process::run('docker-compose -f docker/docker-compose.yml down'); + } + } + + /** + * Test that validates environment variable loading in Docker context + * + * @group docker + */ + #[Test] + public function docker_environment_variables_are_loaded_correctly() + { + if (!$this->isDockerAvailable()) { + $this->markTestSkipped('Docker integration test requires Docker to be running'); + } + + // Create test .env file + $testEnv = base_path('.env.docker.test'); + file_put_contents($testEnv, " +APP_NAME=DockerTest +DB_HOST=db +OLLAMA_BASE_URL=http://ollama:11434 +LLM_PRIMARY_PROVIDER=ollama +QUEUE_CONNECTION=database +ANALYSIS_ASYNC_ENABLED=true +"); + + try { + // Start containers with test env + $result = Process::run("docker-compose -f docker/docker-compose.yml --env-file {$testEnv} config"); + + $this->assertTrue($result->successful()); + $this->assertStringContains('DB_HOST: db', $result->output()); + $this->assertStringContains('OLLAMA_BASE_URL: http://ollama:11434', $result->output()); + + } finally { + // Cleanup + if (file_exists($testEnv)) { + unlink($testEnv); + } + } + } + + #[Test] + public function docker_volumes_are_properly_configured() + { + $result = Process::run('docker-compose -f docker/docker-compose.yml config'); + + $this->assertTrue($result->successful()); + + // Check that essential volumes are configured + $output = $result->output(); + $this->assertStringContains('vendor_data:', $output); + $this->assertStringContains('node_modules_data:', $output); + $this->assertStringContains('db_data:', $output); + $this->assertStringContains('ollama_data:', $output); + } + + #[Test] + public function docker_networks_are_properly_configured() + { + $result = Process::run('docker-compose -f docker/docker-compose.yml config'); + + $this->assertTrue($result->successful()); + + $output = $result->output(); + $this->assertStringContains('networks:', $output); + $this->assertStringContains('nullfake:', $output); + } + + /** + * Test the Docker test script itself + */ + #[Test] + public function docker_test_script_is_executable_and_valid() + { + $testScript = base_path('docker/test-docker.sh'); + + $this->assertFileExists($testScript); + $this->assertTrue(is_executable($testScript), 'Docker test script should be executable'); + + // Check script contains essential tests + $scriptContent = file_get_contents($testScript); + $this->assertStringContains('docker info', $scriptContent); + $this->assertStringContains('docker-compose', $scriptContent); + $this->assertStringContains('curl', $scriptContent); + } + + /** + * Check if Docker is available for testing + */ + private function isDockerAvailable(): bool + { + $result = Process::run('docker info'); + return $result->successful(); + } + + /** + * Test that required Docker images are available or can be pulled + * + * @group docker + * @group slow + */ + #[Test] + public function required_docker_images_are_available() + { + if (!$this->isDockerAvailable()) { + $this->markTestSkipped('Docker is not available'); + } + + $requiredImages = [ + 'php:8.3-fpm', + 'nginx:alpine', + 'mariadb:10.11', + 'ollama/ollama:latest', + ]; + + foreach ($requiredImages as $image) { + $result = Process::timeout(60)->run("docker pull {$image}"); + $this->assertTrue($result->successful(), "Should be able to pull required image: {$image}"); + } + } +} diff --git a/tests/Unit/DockerConfigurationTest.php b/tests/Unit/DockerConfigurationTest.php new file mode 100644 index 0000000..e4b0499 --- /dev/null +++ b/tests/Unit/DockerConfigurationTest.php @@ -0,0 +1,267 @@ +assertFileExists($dockerComposePath); + + $content = file_get_contents($dockerComposePath); + + // Check for essential YAML structure without parsing + $this->assertStringContainsString('services:', $content); + $this->assertStringContainsString('networks:', $content); + $this->assertStringContainsString('volumes:', $content); + $this->assertStringContainsString('version:', $content); + } + + #[Test] + public function docker_compose_contains_required_services() + { + $dockerComposePath = base_path('docker/docker-compose.yml'); + $content = file_get_contents($dockerComposePath); + + $requiredServices = ['app:', 'nginx:', 'db:', 'queue:', 'ollama:']; + + foreach ($requiredServices as $service) { + $this->assertStringContainsString($service, $content, "Missing required service: {$service}"); + } + } + + #[Test] + public function docker_compose_services_have_required_configuration() + { + $dockerComposePath = base_path('docker/docker-compose.yml'); + $content = file_get_contents($dockerComposePath); + + // App service validation + $this->assertStringContainsString('build:', $content); + $this->assertStringContainsString('volumes:', $content); + $this->assertStringContainsString('networks:', $content); + $this->assertStringContainsString('depends_on:', $content); + + // Database service validation + $this->assertStringContainsString('mariadb:10.11', $content); + $this->assertStringContainsString('MARIADB_DATABASE:', $content); + + // Nginx service validation + $this->assertStringContainsString('nginx:alpine', $content); + $this->assertStringContainsString('ports:', $content); + } + + #[Test] + public function dockerfile_exists_and_contains_required_instructions() + { + $dockerfilePath = base_path('docker/Dockerfile'); + + $this->assertFileExists($dockerfilePath); + + $content = file_get_contents($dockerfilePath); + + // Check for essential Dockerfile instructions + $this->assertStringContainsString('FROM php:8.3-fpm', $content); + $this->assertStringContainsString('WORKDIR /var/www/html', $content); + $this->assertStringContainsString('RUN apt-get update', $content); + $this->assertStringContainsString('docker-php-ext-install', $content); + $this->assertStringContainsString('COPY --from=composer', $content); + $this->assertStringContainsString('composer install', $content); + $this->assertStringContainsString('npm ci && npm run build', $content); + $this->assertStringContainsString('ENTRYPOINT', $content); + } + + #[Test] + public function entrypoint_script_exists_and_is_properly_structured() + { + $entrypointPath = base_path('docker/entrypoint.sh'); + + $this->assertFileExists($entrypointPath); + + $content = file_get_contents($entrypointPath); + + // Check for essential entrypoint functionality + $this->assertStringContainsString('#!/bin/bash', $content); + $this->assertStringContainsString('set -e', $content); + $this->assertStringContainsString('mkdir -p storage', $content); + $this->assertStringContainsString('chown -R www-data:www-data', $content); + $this->assertStringContainsString('php artisan key:generate', $content); + $this->assertStringContainsString('php artisan migrate', $content); + $this->assertStringContainsString('exec "$@"', $content); + } + + #[Test] + public function nginx_configuration_is_properly_structured() + { + $nginxConfigPath = base_path('docker/nginx/default.conf'); + + $this->assertFileExists($nginxConfigPath); + + $content = file_get_contents($nginxConfigPath); + + // Check for essential nginx configuration + $this->assertStringContainsString('server {', $content); + $this->assertStringContainsString('listen 80;', $content); + $this->assertStringContainsString('root /var/www/html/public;', $content); + $this->assertStringContainsString('index index.php', $content); + $this->assertStringContainsString('location ~ \.php$', $content); + $this->assertStringContainsString('fastcgi_pass app:9000;', $content); + $this->assertStringContainsString('try_files $uri $uri/ /index.php?$query_string;', $content); + } + + #[Test] + public function php_configuration_has_appropriate_settings() + { + $phpConfigPath = base_path('docker/php/local.ini'); + + $this->assertFileExists($phpConfigPath); + + $content = file_get_contents($phpConfigPath); + + // Check for essential PHP settings for Laravel + $this->assertStringContainsString('memory_limit=512M', $content); + $this->assertStringContainsString('upload_max_filesize=100M', $content); + $this->assertStringContainsString('post_max_size=100M', $content); + $this->assertStringContainsString('max_execution_time=300', $content); + $this->assertStringContainsString('opcache.enable=1', $content); + } + + #[Test] + public function env_example_contains_all_required_variables_for_docker() + { + $envExamplePath = base_path('.env.example'); + + $this->assertFileExists($envExamplePath); + + $content = file_get_contents($envExamplePath); + + // Essential variables for Docker deployment + $requiredVars = [ + 'APP_NAME=', + 'APP_ENV=', + 'APP_KEY=', + 'APP_DEBUG=', + 'APP_URL=', + 'DB_CONNECTION=', + 'DB_HOST=', + 'DB_PORT=', + 'DB_DATABASE=', + 'DB_USERNAME=', + 'DB_PASSWORD=', + 'QUEUE_CONNECTION=', + 'ANALYSIS_ASYNC_ENABLED=', + 'LLM_PRIMARY_PROVIDER=', + 'OPENAI_API_KEY=', + 'OLLAMA_BASE_URL=', + 'AMAZON_REVIEW_SERVICE=', + ]; + + foreach ($requiredVars as $var) { + $this->assertStringContainsString($var, $content, "Missing required environment variable: {$var}"); + } + } + + #[Test] + public function docker_test_script_exists_and_has_proper_structure() + { + $testScriptPath = base_path('docker/test-docker.sh'); + + $this->assertFileExists($testScriptPath); + + $content = file_get_contents($testScriptPath); + + // Check for essential test script functionality + $this->assertStringContainsString('#!/bin/bash', $content); + $this->assertStringContainsString('set -e', $content); + $this->assertStringContainsString('docker info', $content); + $this->assertStringContainsString('docker-compose', $content); + $this->assertStringContainsString('curl', $content); + } + + #[Test] + public function docker_readme_exists_and_contains_essential_information() + { + $dockerReadmePath = base_path('docker/README.md'); + + $this->assertFileExists($dockerReadmePath); + + $content = file_get_contents($dockerReadmePath); + + // Check for essential documentation sections + $this->assertStringContainsString('# Docker Setup', $content); + $this->assertStringContainsString('Quick Start', $content); + $this->assertStringContainsString('docker-compose', $content); + $this->assertStringContainsString('localhost:8080', $content); + $this->assertStringContainsString('Troubleshooting', $content); + } + + #[Test] + public function docker_volumes_are_properly_defined() + { + $dockerComposePath = base_path('docker/docker-compose.yml'); + $content = file_get_contents($dockerComposePath); + + $requiredVolumes = ['db_data:', 'ollama_data:', 'vendor_data:', 'node_modules_data:']; + + $this->assertStringContainsString('volumes:', $content); + + foreach ($requiredVolumes as $volume) { + $this->assertStringContainsString($volume, $content, "Missing required volume: {$volume}"); + } + } + + #[Test] + public function docker_networks_are_properly_configured() + { + $dockerComposePath = base_path('docker/docker-compose.yml'); + $content = file_get_contents($dockerComposePath); + + $this->assertStringContainsString('networks:', $content); + $this->assertStringContainsString('nullfake:', $content); + $this->assertStringContainsString('driver: bridge', $content); + } + + #[Test] + public function docker_ports_are_properly_exposed() + { + $dockerComposePath = base_path('docker/docker-compose.yml'); + $content = file_get_contents($dockerComposePath); + + // Check nginx ports + $this->assertStringContainsString('8080:80', $content); + + // Check database ports + $this->assertStringContainsString('3307:3306', $content); + + // Check Ollama ports + $this->assertStringContainsString('11434:11434', $content); + } + + #[Test] + public function docker_environment_variables_are_properly_set() + { + $dockerComposePath = base_path('docker/docker-compose.yml'); + $content = file_get_contents($dockerComposePath); + + // Check app environment variables + $this->assertStringContainsString('DB_HOST=db', $content); + $this->assertStringContainsString('OLLAMA_BASE_URL=http://ollama:11434', $content); + + // Check database environment variables + $this->assertStringContainsString('MARIADB_DATABASE: faker', $content); + $this->assertStringContainsString('MARIADB_USER: faker', $content); + $this->assertStringContainsString('MARIADB_PASSWORD: password', $content); + } +}