name: BCards Deployment Pipeline on: push: branches: - main - 'Release/*' # PRs apenas validam, não fazem deploy pull_request: branches: [ main ] types: [opened, synchronize, reopened] env: REGISTRY: registry.redecarneir.us IMAGE_NAME: bcards MONGODB_HOST: 192.168.0.100:27017 jobs: test: name: Run Tests runs-on: ubuntu-latest steps: - name: Test info run: | echo "🧪 Executando testes para ${{ github.ref_name }}" echo "🎯 Trigger: ${{ github.event_name }}" # Verificar se deve pular testes SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}" if [ "$SKIP_TESTS" == "true" ]; then echo "⚠️ Testes PULADOS" echo "TESTS_SKIPPED=true" >> $GITHUB_ENV else echo "✅ Executando testes" echo "TESTS_SKIPPED=false" >> $GITHUB_ENV fi - name: Checkout code if: env.TESTS_SKIPPED == 'false' uses: actions/checkout@v4 - name: Setup .NET 8 if: env.TESTS_SKIPPED == 'false' uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - name: Cache dependencies if: env.TESTS_SKIPPED == 'false' uses: actions/cache@v3 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- - name: Restore dependencies if: env.TESTS_SKIPPED == 'false' run: dotnet restore - name: Build solution if: env.TESTS_SKIPPED == 'false' run: dotnet build --no-restore --configuration Release - name: Run unit tests if: env.TESTS_SKIPPED == 'false' run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" # Job específico para validação de PRs (sem deploy) pr-validation: name: PR Validation runs-on: ubuntu-latest needs: [test] if: github.event_name == 'pull_request' steps: - name: PR Validation Summary run: | echo "✅ Pull Request Validation Summary" echo "🎯 Target Branch: ${{ github.base_ref }}" echo "📂 Source Branch: ${{ github.head_ref }}" echo "🧪 Tests: ${{ needs.test.result }}" echo "👤 Author: ${{ github.event.pull_request.user.login }}" echo "📝 Title: ${{ github.event.pull_request.title }}" echo "" echo "✨ PR está pronto para merge!" build-and-push: name: Build and Push Image runs-on: [self-hosted, arm64, bcards] needs: [test] # Só faz build/push em push (não em PR) if: github.event_name == 'push' && (needs.test.result == 'success' || needs.test.result == 'skipped') steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: platforms: linux/arm64 - name: Determine build settings id: settings run: | BRANCH_NAME="${{ github.ref_name }}" if [ "$BRANCH_NAME" = "main" ]; then # Main = Produção (ARM64) - usando Dockerfile simples echo "tag=latest" >> $GITHUB_OUTPUT echo "platform=linux/arm64" >> $GITHUB_OUTPUT echo "environment=Production" >> $GITHUB_OUTPUT echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT echo "deploy_target=production" >> $GITHUB_OUTPUT elif [[ "$BRANCH_NAME" == Release/* ]]; then # Release = Swarm tests (Orange Pi arm64) - usando Dockerfile simples também VERSION_RAW=${BRANCH_NAME#Release/} # Only remove V/v if it's at the start and followed by a number (like v1.0.0) VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/') [ -z "$VERSION" ] && VERSION="0.0.1" echo "tag=$VERSION" >> $GITHUB_OUTPUT echo "platform=linux/arm64" >> $GITHUB_OUTPUT echo "environment=Testing" >> $GITHUB_OUTPUT echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT echo "deploy_target=testing" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT fi COMMIT_SHA=${{ github.sha }} SHORT_COMMIT=${COMMIT_SHA:0:7} echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT echo "📦 Tag: ${{ steps.settings.outputs.tag }}" echo "🏗️ Platform: ${{ steps.settings.outputs.platform }}" echo "🌍 Environment: ${{ steps.settings.outputs.environment }}" echo "🎯 Target: ${{ steps.settings.outputs.deploy_target }}" - name: Build and push image run: | echo "🏗️ Building image for ${{ steps.settings.outputs.deploy_target }}..." # Debug das variáveis echo "Platform: ${{ steps.settings.outputs.platform }}" echo "Dockerfile: ${{ steps.settings.outputs.dockerfile }}" echo "Tag: ${{ steps.settings.outputs.tag }}" # Verificar se o Dockerfile existe if [ ! -f "${{ steps.settings.outputs.dockerfile }}" ]; then echo "❌ Dockerfile não encontrado: ${{ steps.settings.outputs.dockerfile }}" echo "📂 Arquivos na raiz:" ls -la echo "📂 Arquivos em src/BCards.Web/:" ls -la src/BCards.Web/ || echo "Diretório não existe" exit 1 else echo "✅ Dockerfile encontrado: ${{ steps.settings.outputs.dockerfile }}" fi # Build para a plataforma correta if [ "${{ steps.settings.outputs.deploy_target }}" = "production" ]; then # Build para produção (main branch) - Usa Configuration=Release (padrão) docker buildx build \ --platform ${{ steps.settings.outputs.platform }} \ --file ${{ steps.settings.outputs.dockerfile }} \ --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \ --push \ --progress=plain \ . else # Build para staging (Release branches) - Usa Configuration=Testing para habilitar código de teste docker buildx build \ --platform ${{ steps.settings.outputs.platform }} \ --file ${{ steps.settings.outputs.dockerfile }} \ --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \ --push \ --build-arg BUILD_CONFIGURATION=Testing \ --build-arg VERSION=${{ steps.settings.outputs.version || 'latest' }} \ --build-arg COMMIT=${{ steps.settings.outputs.commit }} \ --progress=plain \ . fi deploy-production: name: Deploy to Production (ARM - OCI) runs-on: ubuntu-latest needs: [build-and-push] if: github.ref_name == 'main' environment: production steps: - name: Checkout code uses: actions/checkout@v4 - name: Create Production Configuration run: | echo "🔧 Creating appsettings.Production.json with environment variables..." # Cria o arquivo de configuração para produção cat > appsettings.Production.json << 'CONFIG_EOF' { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning", "BCards": "Information", "BCards.Web.Services.GridFSImageStorage": "Debug" }, "Console": { "IncludeScopes": false, "LogLevel": { "Default": "Information" } }, "File": { "Path": "/app/logs/bcards-{Date}.log", "LogLevel": { "Default": "Information" } } }, "AllowedHosts": "*", "Stripe": { "PublishableKey": "${{ vars.STRIPE_PUBLISHABLE_KEY }}", "SecretKey": "${{ secrets.STRIPE_SECRET_KEY }}", "WebhookSecret": "${{ secrets.STRIPE_WEBHOOK_SECRET }}", "Environment": "${{ vars.STRIPE_ENVIRONMENT || 'test' }}" }, "Authentication": { "Google": { "ClientId": "${{ vars.GOOGLE_CLIENT_ID }}", "ClientSecret": "${{ secrets.GOOGLE_CLIENT_SECRET }}" }, "Microsoft": { "ClientId": "${{ vars.MICROSOFT_CLIENT_ID }}", "ClientSecret": "${{ secrets.MICROSOFT_CLIENT_SECRET }}" } }, "SendGrid": { "ApiKey": "${{ secrets.SENDGRID_API_KEY }}", "FromEmail": "${{ vars.SENDGRID_FROM_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}", "FromName": "${{ vars.SENDGRID_FROM_NAME || 'Ricardo Carneiro' }}" }, "Plans": { "Basic": { "Name": "Básico", "PriceId": "price_1RycPaBMIadsOxJVKioZZofK", "Price": 5.90, "MaxPages": 3, "MaxLinks": 8, "AllowPremiumThemes": false, "AllowProductLinks": false, "AllowAnalytics": true, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ], "Interval": "month" }, "Professional": { "Name": "Profissional", "PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj", "Price": 12.90, "MaxPages": 5, "MaxLinks": 20, "AllowPremiumThemes": false, "AllowProductLinks": false, "AllowAnalytics": true, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ], "Interval": "month" }, "Premium": { "Name": "Premium", "PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu", "Price": 19.90, "MaxPages": 15, "MaxLinks": -1, "AllowPremiumThemes": true, "AllowProductLinks": false, "AllowAnalytics": true, "SpecialModeration": false, "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ], "Interval": "month" }, "PremiumAffiliate": { "Name": "Premium+Afiliados", "PriceId": "price_1RycTaBMIadsOxJVeDLseXQq", "Price": 29.90, "MaxPages": 15, "MaxLinks": -1, "AllowPremiumThemes": true, "AllowProductLinks": true, "AllowAnalytics": true, "SpecialModeration": true, "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ], "Interval": "month" }, "BasicYearly": { "Name": "Básico Anual", "PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS", "Price": 59.00, "MaxPages": 3, "MaxLinks": 8, "AllowPremiumThemes": false, "AllowProductLinks": false, "AllowAnalytics": true, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ], "Interval": "year" }, "ProfessionalYearly": { "Name": "Profissional Anual", "PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm", "Price": 129.00, "MaxPages": 5, "MaxLinks": 20, "AllowPremiumThemes": false, "AllowProductLinks": false, "AllowAnalytics": true, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ], "Interval": "year" }, "PremiumYearly": { "Name": "Premium Anual", "PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m", "Price": 199.00, "MaxPages": 15, "MaxLinks": -1, "AllowPremiumThemes": true, "AllowProductLinks": false, "AllowAnalytics": true, "SpecialModeration": false, "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ], "Interval": "year" }, "PremiumAffiliateYearly": { "Name": "Premium+Afiliados Anual", "PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1", "Price": 299.00, "MaxPages": 15, "MaxLinks": -1, "AllowPremiumThemes": true, "AllowProductLinks": true, "AllowAnalytics": true, "SpecialModeration": true, "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ], "Interval": "year" } }, "Moderation": { "PriorityTimeframes": { "Trial": "7.00:00:00", "Basic": "7.00:00:00", "Professional": "3.00:00:00", "Premium": "1.00:00:00" }, "MaxAttempts": 3, "ModeratorEmail": "${{ vars.MODERATOR_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}", "ModeratorEmails": [ "${{ vars.MODERATOR_EMAIL_1 || 'rrcgoncalves@gmail.com' }}", "${{ vars.MODERATOR_EMAIL_2 || 'rirocarneiro@gmail.com' }}" ] }, "MongoDb": { "ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin", "DatabaseName": "BCardsDB", "MaxConnectionPoolSize": 100, "ConnectTimeout": "30s", "ServerSelectionTimeout": "30s", "SocketTimeout": "30s" }, "BaseUrl": "https://bcards.site", "Environment": { "Name": "Production", "IsStagingEnvironment": false, "AllowTestData": false, "EnableDetailedErrors": false }, "Performance": { "EnableCaching": true, "CacheExpirationMinutes": 30, "EnableCompression": true, "EnableResponseCaching": true }, "Security": { "EnableHttpsRedirection": true, "EnableHsts": true, "RequireHttpsMetadata": true }, "HealthChecks": { "Enabled": true, "Endpoints": { "Health": "/health", "Ready": "/ready", "Live": "/live" }, "MongoDb": { "Enabled": true, "Timeout": "10s" } }, "Features": { "EnablePreviewMode": true, "EnableModerationWorkflow": true, "EnableAnalytics": true, "EnableFileUploads": true, "MaxFileUploadSize": "5MB" }, "Serilog": { "OpenSearchUrl": "${{ vars.OPENSEARCH_URL || 'http://localhost:9201' }}" } } CONFIG_EOF echo "✅ Configuration file created!" echo "🔍 File content (sensitive data masked):" cat appsettings.Production.json | sed 's/"[^"]*_[0-9A-Za-z_]*"/"***MASKED***"/g' - name: Deploy to Production Swarm run: | echo "🚀 Deploying to production Docker Swarm (ARM64)..." # Configura SSH mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa # Adiciona hosts conhecidos ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts # Testa a chave SSH ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded" # Upload configuration and stack file to swarm manager echo "📤 Uploading files to Swarm manager..." scp -o StrictHostKeyChecking=no appsettings.Production.json ubuntu@141.148.162.114:/tmp/ scp -o StrictHostKeyChecking=no deploy/docker-stack.yml ubuntu@141.148.162.114:/tmp/ # Deploy to Docker Swarm ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 << 'EOF' set -e echo "🔄 Updating Docker Swarm stack..." # Update Docker config with new appsettings echo "📝 Updating bcards-appsettings config..." # Remove old config (will fail if in use, that's ok - swarm will use it until update) docker config rm bcards-appsettings 2>/dev/null || echo "Config in use or doesn't exist, will create new one" # Create new config with timestamp to force update CONFIG_NAME="bcards-appsettings-$(date +%s)" docker config create ${CONFIG_NAME} /tmp/appsettings.Production.json # Update stack file to use new config name sed "s/bcards-appsettings/${CONFIG_NAME}/g" /tmp/docker-stack.yml > /tmp/docker-stack-updated.yml echo "🐳 Deploying stack to Swarm (rolling update, zero downtime)..." docker stack deploy -c /tmp/docker-stack-updated.yml bcards --with-registry-auth echo "⏳ Waiting for service to update..." sleep 10 # Show service status docker service ls --filter name=bcards_bcards-app docker service ps bcards_bcards-app --no-trunc --filter "desired-state=running" | head -10 echo "🧹 Cleaning up standalone containers if they exist..." docker stop bcards-prod 2>/dev/null || echo "No standalone container to stop" docker rm bcards-prod 2>/dev/null || echo "No standalone container to remove" # Clean up temp files rm -f /tmp/appsettings.Production.json /tmp/docker-stack.yml /tmp/docker-stack-updated.yml echo "✅ Swarm stack updated successfully!" EOF - name: Health Check Production run: | echo "🏥 Verificando saúde dos servidores de produção..." sleep 30 # Verifica Servidor 1 echo "Verificando Servidor 1 (ARM)..." ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 1 pode não estar respondendo"' # Verifica Servidor 2 echo "Verificando Servidor 2 (ARM)..." ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 2 pode não estar respondendo"' deploy-test: name: Deploy to Release Swarm (ARM) runs-on: [self-hosted, arm64, bcards] needs: [build-and-push] if: startsWith(github.ref_name, 'Release/') steps: - name: Checkout code uses: actions/checkout@v4 - name: Extract version id: version run: | BRANCH_NAME="${{ github.ref_name }}" VERSION_RAW=${BRANCH_NAME#Release/} # Only remove V/v if it's at the start and followed by a number (like v1.0.0) VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/') [ -z "$VERSION" ] && VERSION="0.0.1" echo "version=$VERSION" >> $GITHUB_OUTPUT echo "📦 Deploying version: $VERSION" - name: Prepare release stack manifest run: | mkdir -p artifacts BCARDS_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} # Replace ${BCARDS_IMAGE} with actual image name using sed sed "s|\${BCARDS_IMAGE}|${BCARDS_IMAGE}|g" deploy/docker-stack.release.yml > artifacts/docker-stack.release.yml echo "🔧 Generated manifest with image: ${BCARDS_IMAGE}" echo "📄 Manifest content:" head -10 artifacts/docker-stack.release.yml - name: Deploy to release swarm run: | echo "🚀 Deploying release stack to Orange Pi swarm..." docker stack deploy -c artifacts/docker-stack.release.yml bcards-release - name: Await release service readiness run: | echo "⏳ Aguardando serviço bcards-release estabilizar..." ATTEMPTS=30 while [ $ATTEMPTS -gt 0 ]; do REPLICAS=$(docker service ls --filter name=bcards-release_bcards-release --format '{{.Replicas}}') if [ "$REPLICAS" = "1/1" ]; then echo "✅ Serviço com $REPLICAS réplica" break fi echo "Atual: ${REPLICAS:-N/A}; aguardando..." sleep 5 ATTEMPTS=$((ATTEMPTS-1)) done if [ "$REPLICAS" != "1/1" ]; then echo "❌ Serviço não atingiu 1/1 réplica" docker service ps bcards-release_bcards-release exit 1 fi docker service ps bcards-release_bcards-release cleanup: name: Cleanup Old Resources runs-on: ubuntu-latest needs: [deploy-production, deploy-test] if: always() && (needs.deploy-production.result == 'success' || needs.deploy-test.result == 'success') steps: - name: Cleanup containers and images run: | echo "🧹 Limpando recursos antigos..." if [ "${{ github.ref_name }}" = "main" ]; then mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded" for server in 141.148.162.114 129.146.116.218; do echo "🧹 Limpando servidor $server..." ssh -o StrictHostKeyChecking=no ubuntu@$server << 'EOF' docker container prune -f docker image prune -f docker network prune -f EOF done else echo "ℹ️ Release branch: limpeza remota ignorada (Swarm gerencia recursos)." fi echo "✅ Limpeza concluída!" deployment-summary: name: Deployment Summary runs-on: ubuntu-latest needs: [deploy-production, deploy-test] if: always() steps: - name: Summary run: | echo "📋 DEPLOYMENT SUMMARY" echo "====================" echo "🎯 Branch: ${{ github.ref_name }}" echo "🔑 Commit: ${{ github.sha }}" echo "🏗️ Registry: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" if [ "${{ github.ref_name }}" = "main" ]; then echo "🌍 Environment: Production (Swarm ARM)" echo "🖥️ Servers: 141.148.162.114, 129.146.116.218" echo "📦 Tag: latest" echo "🔗 Status: ${{ needs.deploy-production.result }}" else echo "🌍 Environment: Release (Swarm ARM)" echo "🖥️ Servers: 141.148.162.114, 129.146.116.218" echo "📦 Branch Tag: ${{ github.ref_name }}" echo "🔗 Status: ${{ needs.deploy-test.result }}" fi echo "====================" echo "✅ Pipeline completed!"