From 1963b107496bcd1f05adbd6e5ecd8eed83dc7299 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Fri, 7 Nov 2025 18:14:11 +0100 Subject: [PATCH] feat: Integrate Ansible playbooks into CI/CD workflows - Add deploy-application-code.yml for Git-based code deployment - Add install-composer-dependencies.yml for dependency installation - Add deploy-image.yml for Docker image deployment - Update build-image.yml to use Ansible playbooks - Update manual-deploy.yml to use Ansible playbooks - Add ANSIBLE_VAULT_PASSWORD secret handling --- .gitea/workflows/build-image.yml | 405 +++++++----------- .gitea/workflows/manual-deploy.yml | 339 +++++---------- deployment/ansible/group_vars/production.yml | 93 ---- .../playbooks/deploy-application-code.yml | 123 ++++++ deployment/ansible/playbooks/deploy-image.yml | 142 ++++++ .../install-composer-dependencies.yml | 78 ++++ .../Console/Layout/Components/StatusType.php | 16 + .../ErrorHandlerInterface.php | 8 + .../Renderer/HtmlErrorRenderer.php | 14 - .../ShutdownHandlerInterface.php | 8 + 10 files changed, 636 insertions(+), 590 deletions(-) delete mode 100644 deployment/ansible/group_vars/production.yml create mode 100644 deployment/ansible/playbooks/deploy-application-code.yml create mode 100644 deployment/ansible/playbooks/deploy-image.yml create mode 100644 deployment/ansible/playbooks/install-composer-dependencies.yml create mode 100644 src/Framework/Console/Layout/Components/StatusType.php create mode 100644 src/Framework/ExceptionHandling/ErrorHandlerInterface.php delete mode 100644 src/Framework/ExceptionHandling/Renderer/HtmlErrorRenderer.php create mode 100644 src/Framework/ExceptionHandling/ShutdownHandlerInterface.php diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index b43bb288..95d6534a 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -963,169 +963,57 @@ jobs: chmod 600 ~/.ssh/production ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts - - name: Deploy to Staging Server + - name: Install Ansible run: | - set -e - - DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}" - REGISTRY_HOST="${{ env.REGISTRY }}" - IMAGE_NAME="${{ env.IMAGE_NAME }}" - - DEFAULT_IMAGE="${REGISTRY_HOST}/${IMAGE_NAME}:latest" - - # Always use latest image - if a build happened, it would have pushed to latest anyway - # Using latest ensures we always get the most recent image, whether it was just built or not - SELECTED_IMAGE="$DEFAULT_IMAGE" + sudo apt-get update + sudo apt-get install -y ansible python3-pip + pip3 install --user ansible-core docker - STACK_PATH_DISPLAY="~/deployment/stacks/staging" - - SELECTED_TAG="${SELECTED_IMAGE##*:}" - SELECTED_REPO="${SELECTED_IMAGE%:*}" - - if [ -z "$SELECTED_REPO" ] || [ "$SELECTED_REPO" = "$SELECTED_IMAGE" ]; then - FALLBACK_IMAGE="$DEFAULT_IMAGE" + - name: Create Ansible Vault password file + run: | + if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass + chmod 600 /tmp/vault_pass + echo "✅ Vault password file created" else - FALLBACK_IMAGE="${SELECTED_REPO}:latest" + echo "âš ī¸ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file" + touch /tmp/vault_pass + chmod 600 /tmp/vault_pass fi - - echo "🚀 Starting staging deployment..." - echo " Image: ${SELECTED_IMAGE}" - echo " Tag: ${SELECTED_TAG}" - echo " Host: ${DEPLOYMENT_HOST}" - echo " Stack: ${STACK_PATH_DISPLAY}" - - FULL_IMAGE_ARG=$(printf '%q' "$SELECTED_IMAGE") - FALLBACK_IMAGE_ARG=$(printf '%q' "$FALLBACK_IMAGE") - IMAGE_NAME_ARG=$(printf '%q' "$IMAGE_NAME") - REGISTRY_ARG=$(printf '%q' "$REGISTRY_HOST") - - ssh -i ~/.ssh/production \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - deploy@${DEPLOYMENT_HOST} "bash -s -- $FULL_IMAGE_ARG $FALLBACK_IMAGE_ARG $IMAGE_NAME_ARG $REGISTRY_ARG" <<'EOF' - set -e - - FULL_IMAGE="$1" - FALLBACK_IMAGE="$2" - IMAGE_NAME="$3" - REGISTRY="$4" - shift 4 - - CURRENT_USER="$(whoami)" - USER_HOME="$(getent passwd "$CURRENT_USER" | cut -d: -f6 2>/dev/null)" - [ -z "$USER_HOME" ] && USER_HOME="$HOME" - [ -z "$USER_HOME" ] && USER_HOME="/home/$CURRENT_USER" - - STACK_TARGET="${USER_HOME}/deployment/stacks/staging" - # Ensure staging stack directory exists - mkdir -p "${STACK_TARGET}" - cd "${STACK_TARGET}" - - declare -a REGISTRY_TARGETS=() - if [ -n "${REGISTRY}" ]; then - REGISTRY_TARGETS+=("${REGISTRY}") - fi - for IMAGE_REF in "${FULL_IMAGE}" "${FALLBACK_IMAGE}"; do - if [ -n "${IMAGE_REF}" ]; then - HOST_PART="${IMAGE_REF%%/*}" - if [ -n "${HOST_PART}" ]; then - if ! printf '%s\n' "${REGISTRY_TARGETS[@]}" | grep -qx "${HOST_PART}"; then - REGISTRY_TARGETS+=("${HOST_PART}") - fi - fi - fi - done - - for TARGET in "${REGISTRY_TARGETS[@]}"; do - [ -z "${TARGET}" ] && continue - echo "🔐 Logging in to Docker registry ${TARGET}..." - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${TARGET}" \ - -u "${{ secrets.REGISTRY_USER }}" \ - --password-stdin || echo "âš ī¸ Registry login failed for ${TARGET}, continuing..." - done - - DEPLOY_IMAGE="$FULL_IMAGE" - echo "đŸ“Ĩ Pulling image ${DEPLOY_IMAGE}..." - if ! docker pull "${DEPLOY_IMAGE}"; then - if [ -n "${FALLBACK_IMAGE}" ] && [ "${DEPLOY_IMAGE}" != "${FALLBACK_IMAGE}" ]; then - echo "âš ī¸ Failed to pull ${DEPLOY_IMAGE}, attempting fallback ${FALLBACK_IMAGE}" - if docker pull "${FALLBACK_IMAGE}"; then - DEPLOY_IMAGE="${FALLBACK_IMAGE}" - echo "â„šī¸ Using fallback image ${DEPLOY_IMAGE}" - else - echo "❌ Failed to pull fallback image ${FALLBACK_IMAGE}" - exit 1 - fi - else - echo "❌ Failed to pull image ${DEPLOY_IMAGE}" - exit 1 - fi - fi - - # Copy base and staging docker-compose files if they don't exist - if [ ! -f docker-compose.base.yml ]; then - echo "âš ī¸ docker-compose.base.yml not found, copying from repo..." - cp /workspace/repo/docker-compose.base.yml . || { - echo "❌ Failed to copy docker-compose.base.yml" - exit 1 - } - fi - - if [ ! -f docker-compose.staging.yml ]; then - echo "âš ī¸ docker-compose.staging.yml not found, copying from repo..." - cp /workspace/repo/docker-compose.staging.yml . || { - echo "❌ Failed to copy docker-compose.staging.yml" - exit 1 - } - fi - - # Update docker-compose.staging.yml with new image tag - echo "📝 Updating docker-compose.staging.yml with new image tag..." - sed -i "s|image:.*/${IMAGE_NAME}:.*|image: ${DEPLOY_IMAGE}|g" docker-compose.staging.yml - - echo "✅ Updated docker-compose.staging.yml:" - grep "image:" docker-compose.staging.yml | head -5 - - # Ensure networks exist - echo "🔗 Ensuring Docker networks exist..." - docker network create traefik-public 2>/dev/null || true - docker network create staging-internal 2>/dev/null || true - - echo "🔄 Starting/updating services..." - # Use --pull missing instead of --pull always since we already pulled the specific image - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -d --pull missing --force-recreate || { - echo "❌ Failed to start services" - exit 1 - } - - echo "âŗ Waiting for services to start..." - sleep 15 - - # Pull latest code from Git repository - always sync code when deploying - echo "🔄 Pulling latest code from Git repository in staging-app container..." - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-app bash -c "cd /var/www/html && git -c safe.directory=/var/www/html fetch origin staging && git -c safe.directory=/var/www/html reset --hard origin/staging && git -c safe.directory=/var/www/html clean -fd" || echo "âš ī¸ Git pull failed, container will sync on next restart" - - # Also trigger a restart to ensure entrypoint script runs - echo "🔄 Restarting staging-app to ensure all services are up-to-date..." - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-app || echo "âš ī¸ Failed to restart staging-app" - - # Fix nginx upstream configuration - critical fix for 502 errors - # sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in staging-app container - echo "🔧 Fixing nginx PHP-FPM upstream configuration (post-deploy fix)..." - sleep 5 - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "âš ī¸ Upstream fix (127.0.0.1) failed" - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "âš ī¸ Upstream fix (localhost) failed" - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx nginx -t && docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-nginx || echo "âš ī¸ Nginx config test or restart failed" - echo "✅ Nginx configuration fixed and reloaded" - - echo "âŗ Waiting for services to stabilize..." - sleep 10 - echo "📊 Container status:" - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml ps - - echo "✅ Staging deployment completed!" - EOF + - name: Deploy Application Code to Staging + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-application-code.yml \ + -e "deployment_environment=staging" \ + -e "deployment_hosts=production" \ + -e "git_branch=staging" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production + + - name: Install Composer Dependencies + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/install-composer-dependencies.yml \ + -e "deployment_environment=staging" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production + + - name: Deploy Docker Image to Staging + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-image.yml \ + -e "deployment_environment=staging" \ + -e "deployment_hosts=production" \ + -e "image_tag=latest" \ + -e "docker_registry=${{ env.REGISTRY }}" \ + -e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \ + -e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production - name: Wait for deployment to stabilize run: sleep 30 @@ -1133,16 +1021,42 @@ jobs: - name: Health check id: health run: | + echo "🔍 Performing health checks..." + + # Basic health check + BASIC_HEALTH_OK=false for i in {1..10}; do - if curl -f -k https://staging.michaelschiemer.de/health; then - echo "✅ Health check passed" - exit 0 + if curl -f -k -s https://staging.michaelschiemer.de/health > /dev/null 2>&1; then + echo "✅ Basic health check passed" + BASIC_HEALTH_OK=true + break fi echo "âŗ Waiting for staging service... (attempt $i/10)" sleep 10 done - echo "❌ Health check failed" - exit 1 + + if [ "$BASIC_HEALTH_OK" != "true" ]; then + echo "❌ Basic health check failed" + exit 1 + fi + + # Extended health check (if available) + echo "🔍 Checking extended health status..." + HEALTH_SUMMARY=$(curl -f -k -s https://staging.michaelschiemer.de/admin/health/api/summary 2>/dev/null || echo "") + if [ -n "$HEALTH_SUMMARY" ]; then + OVERALL_STATUS=$(echo "$HEALTH_SUMMARY" | grep -o '"overall_status":"[^"]*"' | cut -d'"' -f4 || echo "unknown") + echo "📊 Overall health status: $OVERALL_STATUS" + if [ "$OVERALL_STATUS" = "unhealthy" ]; then + echo "âš ī¸ Extended health check shows unhealthy status" + echo " Health summary: $HEALTH_SUMMARY" + else + echo "✅ Extended health check passed" + fi + else + echo "â„šī¸ Extended health check endpoint not available (this is OK)" + fi + + echo "✅ All health checks completed" - name: Notify deployment success if: success() @@ -1204,14 +1118,27 @@ jobs: chmod 600 ~/.ssh/production ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts - - name: Deploy to Production Server + - name: Install Ansible + run: | + sudo apt-get update + sudo apt-get install -y ansible python3-pip + pip3 install --user ansible-core docker + + - name: Create Ansible Vault password file + run: | + if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass + chmod 600 /tmp/vault_pass + echo "✅ Vault password file created" + else + echo "âš ī¸ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file" + touch /tmp/vault_pass + chmod 600 /tmp/vault_pass + fi + + - name: Determine image tag + id: image_tag run: | - set -e - - DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}" - REGISTRY="${{ env.REGISTRY }}" - IMAGE_NAME="${{ env.IMAGE_NAME }}" - # Get image tag from build job output with fallback IMAGE_TAG="${{ needs.build.outputs.image_tag }}" @@ -1229,76 +1156,42 @@ jobs: fi fi - FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" - STACK_PATH="~/deployment/stacks/application" - - echo "🚀 Starting production deployment..." - echo " Image: ${FULL_IMAGE}" - echo " Tag: ${IMAGE_TAG}" - echo " Host: ${DEPLOYMENT_HOST}" - echo " Stack: ${STACK_PATH}" + echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT + echo "đŸ“Ļ Image Tag: ${IMAGE_TAG}" - echo "📋 Deploying docker-compose configuration files..." - cd /workspace/repo - scp -i ~/.ssh/production \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - docker-compose.base.yml docker-compose.production.yml \ - deploy@${DEPLOYMENT_HOST}:${STACK_PATH}/ || { - echo "❌ Failed to deploy docker-compose files" - exit 1 - } - echo "✅ Docker Compose files deployed successfully" + - name: Deploy Application Code to Production + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-application-code.yml \ + -e "deployment_environment=production" \ + -e "deployment_hosts=production" \ + -e "git_branch=main" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production - ssh -i ~/.ssh/production \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - deploy@${DEPLOYMENT_HOST} < /dev/null 2>&1; then + echo "✅ Basic health check passed" + BASIC_HEALTH_OK=true + break fi echo "âŗ Waiting for production service... (attempt $i/10)" sleep 10 done - echo "❌ Health check failed" - exit 1 + + if [ "$BASIC_HEALTH_OK" != "true" ]; then + echo "❌ Basic health check failed" + exit 1 + fi + + # Extended health check (if available) + echo "🔍 Checking extended health status..." + HEALTH_SUMMARY=$(curl -f -k -s https://michaelschiemer.de/admin/health/api/summary 2>/dev/null || echo "") + if [ -n "$HEALTH_SUMMARY" ]; then + OVERALL_STATUS=$(echo "$HEALTH_SUMMARY" | grep -o '"overall_status":"[^"]*"' | cut -d'"' -f4 || echo "unknown") + echo "📊 Overall health status: $OVERALL_STATUS" + if [ "$OVERALL_STATUS" = "unhealthy" ]; then + echo "âš ī¸ Extended health check shows unhealthy status" + echo " Health summary: $HEALTH_SUMMARY" + else + echo "✅ Extended health check passed" + fi + else + echo "â„šī¸ Extended health check endpoint not available (this is OK)" + fi + + echo "✅ All health checks completed" - name: Notify deployment success if: success() diff --git a/.gitea/workflows/manual-deploy.yml b/.gitea/workflows/manual-deploy.yml index aa8c2656..4c0443d8 100644 --- a/.gitea/workflows/manual-deploy.yml +++ b/.gitea/workflows/manual-deploy.yml @@ -112,173 +112,57 @@ jobs: chmod 600 ~/.ssh/production ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts - - name: Deploy to Staging Server + - name: Install Ansible run: | - set -e - - DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}" - REGISTRY_HOST="${{ needs.determine-image.outputs.registry_host }}" - IMAGE_NAME="${{ needs.determine-image.outputs.image_name }}" - DEPLOY_IMAGE="${{ needs.determine-image.outputs.image_url }}" - IMAGE_TAG="${{ needs.determine-image.outputs.image_tag }}" - - DEFAULT_IMAGE="${REGISTRY_HOST}/${IMAGE_NAME}:latest" - FALLBACK_IMAGE="$DEFAULT_IMAGE" - - SELECTED_IMAGE="$DEPLOY_IMAGE" - if [ -z "$SELECTED_IMAGE" ] || [ "$SELECTED_IMAGE" = "null" ]; then - SELECTED_IMAGE="$DEFAULT_IMAGE" - fi - - STACK_PATH_DISPLAY="~/deployment/stacks/staging" - - SELECTED_TAG="${SELECTED_IMAGE##*:}" - SELECTED_REPO="${SELECTED_IMAGE%:*}" - - if [ -z "$SELECTED_REPO" ] || [ "$SELECTED_REPO" = "$SELECTED_IMAGE" ]; then - FALLBACK_IMAGE="$DEFAULT_IMAGE" - else - FALLBACK_IMAGE="${SELECTED_REPO}:latest" - fi - - echo "🚀 Starting staging deployment..." - echo " Image: ${SELECTED_IMAGE}" - echo " Tag: ${SELECTED_TAG}" - echo " Host: ${DEPLOYMENT_HOST}" - echo " Stack: ${STACK_PATH_DISPLAY}" - - FULL_IMAGE_ARG=$(printf '%q' "$SELECTED_IMAGE") - FALLBACK_IMAGE_ARG=$(printf '%q' "$FALLBACK_IMAGE") - IMAGE_NAME_ARG=$(printf '%q' "$IMAGE_NAME") - REGISTRY_ARG=$(printf '%q' "$REGISTRY_HOST") - - ssh -i ~/.ssh/production \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - deploy@${DEPLOYMENT_HOST} "bash -s -- $FULL_IMAGE_ARG $FALLBACK_IMAGE_ARG $IMAGE_NAME_ARG $REGISTRY_ARG" <<'EOF' - set -e - - FULL_IMAGE="$1" - FALLBACK_IMAGE="$2" - IMAGE_NAME="$3" - REGISTRY="$4" - shift 4 - - CURRENT_USER="$(whoami)" - USER_HOME="$(getent passwd "$CURRENT_USER" | cut -d: -f6 2>/dev/null)" - [ -z "$USER_HOME" ] && USER_HOME="$HOME" - [ -z "$USER_HOME" ] && USER_HOME="/home/$CURRENT_USER" - - STACK_TARGET="${USER_HOME}/deployment/stacks/staging" + sudo apt-get update + sudo apt-get install -y ansible python3-pip + pip3 install --user ansible-core docker - # Ensure staging stack directory exists - mkdir -p "${STACK_TARGET}" - cd "${STACK_TARGET}" - - declare -a REGISTRY_TARGETS=() - if [ -n "${REGISTRY}" ]; then - REGISTRY_TARGETS+=("${REGISTRY}") - fi - for IMAGE_REF in "${FULL_IMAGE}" "${FALLBACK_IMAGE}"; do - if [ -n "${IMAGE_REF}" ]; then - HOST_PART="${IMAGE_REF%%/*}" - if [ -n "${HOST_PART}" ]; then - if ! printf '%s\n' "${REGISTRY_TARGETS[@]}" | grep -qx "${HOST_PART}"; then - REGISTRY_TARGETS+=("${HOST_PART}") - fi - fi - fi - done - - for TARGET in "${REGISTRY_TARGETS[@]}"; do - [ -z "${TARGET}" ] && continue - echo "🔐 Logging in to Docker registry ${TARGET}..." - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${TARGET}" \ - -u "${{ secrets.REGISTRY_USER }}" \ - --password-stdin || echo "âš ī¸ Registry login failed for ${TARGET}, continuing..." - done - - DEPLOY_IMAGE="$FULL_IMAGE" - echo "đŸ“Ĩ Pulling image ${DEPLOY_IMAGE}..." - if ! docker pull "${DEPLOY_IMAGE}"; then - if [ -n "${FALLBACK_IMAGE}" ] && [ "${DEPLOY_IMAGE}" != "${FALLBACK_IMAGE}" ]; then - echo "âš ī¸ Failed to pull ${DEPLOY_IMAGE}, attempting fallback ${FALLBACK_IMAGE}" - if docker pull "${FALLBACK_IMAGE}"; then - DEPLOY_IMAGE="${FALLBACK_IMAGE}" - echo "â„šī¸ Using fallback image ${DEPLOY_IMAGE}" - else - echo "❌ Failed to pull fallback image ${FALLBACK_IMAGE}" - exit 1 - fi - else - echo "❌ Failed to pull image ${DEPLOY_IMAGE}" - exit 1 - fi - fi - - # Copy base and staging docker-compose files if they don't exist - if [ ! -f docker-compose.base.yml ]; then - echo "âš ī¸ docker-compose.base.yml not found, copying from repo..." - cp /workspace/repo/docker-compose.base.yml . || { - echo "❌ Failed to copy docker-compose.base.yml" - exit 1 - } - fi - - if [ ! -f docker-compose.staging.yml ]; then - echo "âš ī¸ docker-compose.staging.yml not found, copying from repo..." - cp /workspace/repo/docker-compose.staging.yml . || { - echo "❌ Failed to copy docker-compose.staging.yml" - exit 1 - } - fi - - # Update docker-compose.staging.yml with new image tag - echo "📝 Updating docker-compose.staging.yml with new image tag..." - sed -i "s|image:.*/${IMAGE_NAME}:.*|image: ${DEPLOY_IMAGE}|g" docker-compose.staging.yml - - echo "✅ Updated docker-compose.staging.yml:" - grep "image:" docker-compose.staging.yml | head -5 - - # Ensure networks exist - echo "🔗 Ensuring Docker networks exist..." - docker network create traefik-public 2>/dev/null || true - docker network create staging-internal 2>/dev/null || true - - echo "🔄 Starting/updating services..." - # Use --pull missing instead of --pull always since we already pulled the specific image - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -d --pull missing --force-recreate || { - echo "❌ Failed to start services" - exit 1 - } - - echo "âŗ Waiting for services to start..." - sleep 15 - - # Pull latest code from Git repository - echo "🔄 Pulling latest code from Git repository in staging-app container..." - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-app bash -c "cd /var/www/html && git -c safe.directory=/var/www/html fetch origin staging && git -c safe.directory=/var/www/html reset --hard origin/staging && git -c safe.directory=/var/www/html clean -fd" || echo "âš ī¸ Git pull failed, container will sync on next restart" - - # Also trigger a restart to ensure entrypoint script runs - echo "🔄 Restarting staging-app to ensure all services are up-to-date..." - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-app || echo "âš ī¸ Failed to restart staging-app" - - # Fix nginx upstream configuration - critical fix for 502 errors - # sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in staging-app container - echo "🔧 Fixing nginx PHP-FPM upstream configuration (post-deploy fix)..." - sleep 5 - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "âš ī¸ Upstream fix (127.0.0.1) failed" - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "âš ī¸ Upstream fix (localhost) failed" - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml exec -T staging-nginx nginx -t && docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-nginx || echo "âš ī¸ Nginx config test or restart failed" - echo "✅ Nginx configuration fixed and reloaded" - - echo "âŗ Waiting for services to stabilize..." - sleep 10 - echo "📊 Container status:" - docker compose -f docker-compose.base.yml -f docker-compose.staging.yml ps - - echo "✅ Staging deployment completed!" - EOF + - name: Create Ansible Vault password file + run: | + if [ -n "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" ]; then + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass + chmod 600 /tmp/vault_pass + echo "✅ Vault password file created" + else + echo "âš ī¸ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file" + touch /tmp/vault_pass + chmod 600 /tmp/vault_pass + fi + + - name: Deploy Application Code to Staging + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-application-code.yml \ + -e "deployment_environment=staging" \ + -e "deployment_hosts=production" \ + -e "git_branch=${{ steps.branch.outputs.BRANCH }}" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production + + - name: Install Composer Dependencies + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/install-composer-dependencies.yml \ + -e "deployment_environment=staging" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production + + - name: Deploy Docker Image to Staging + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-image.yml \ + -e "deployment_environment=staging" \ + -e "deployment_hosts=production" \ + -e "image_tag=${{ needs.determine-image.outputs.image_tag }}" \ + -e "docker_registry=${{ needs.determine-image.outputs.registry_host }}" \ + -e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \ + -e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production - name: Wait for deployment to stabilize run: sleep 30 @@ -353,82 +237,57 @@ jobs: chmod 600 ~/.ssh/production ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts - - name: Deploy to Production Server + - name: Install Ansible run: | - set -e - - DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}" - REGISTRY="${{ needs.determine-image.outputs.registry_host }}" - IMAGE_NAME="${{ needs.determine-image.outputs.image_name }}" - IMAGE_TAG="${{ needs.determine-image.outputs.image_tag }}" - - FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" - STACK_PATH="~/deployment/stacks/application" - - echo "🚀 Starting production deployment..." - echo " Image: ${FULL_IMAGE}" - echo " Tag: ${IMAGE_TAG}" - echo " Host: ${DEPLOYMENT_HOST}" - echo " Stack: ${STACK_PATH}" - - ssh -i ~/.ssh/production \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - deploy@${DEPLOYMENT_HOST} < /tmp/vault_pass + chmod 600 /tmp/vault_pass + echo "✅ Vault password file created" + else + echo "âš ī¸ ANSIBLE_VAULT_PASSWORD secret not set, using empty password file" + touch /tmp/vault_pass + chmod 600 /tmp/vault_pass + fi + + - name: Deploy Application Code to Production + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-application-code.yml \ + -e "deployment_environment=production" \ + -e "deployment_hosts=production" \ + -e "git_branch=${{ steps.branch.outputs.BRANCH }}" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production + + - name: Install Composer Dependencies + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/install-composer-dependencies.yml \ + -e "deployment_environment=production" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production + + - name: Deploy Docker Image to Production + run: | + cd /workspace/repo/deployment/ansible + ansible-playbook -i inventory/production.yml \ + playbooks/deploy-image.yml \ + -e "deployment_environment=production" \ + -e "deployment_hosts=production" \ + -e "image_tag=${{ needs.determine-image.outputs.image_tag }}" \ + -e "docker_registry=${{ needs.determine-image.outputs.registry_host }}" \ + -e "docker_registry_username=${{ secrets.REGISTRY_USER }}" \ + -e "docker_registry_password=${{ secrets.REGISTRY_PASSWORD }}" \ + --vault-password-file /tmp/vault_pass \ + --private-key ~/.ssh/production - name: Wait for deployment to stabilize run: sleep 30 diff --git a/deployment/ansible/group_vars/production.yml b/deployment/ansible/group_vars/production.yml deleted file mode 100644 index b5d12042..00000000 --- a/deployment/ansible/group_vars/production.yml +++ /dev/null @@ -1,93 +0,0 @@ ---- -# Production Deployment - Centralized Variables -# These variables are used across all playbooks - -# System Maintenance -system_update_packages: true -system_apt_upgrade: dist -system_enable_unattended_upgrades: true -system_enable_unattended_reboot: false -system_unattended_reboot_time: "02:00" -system_enable_unattended_timer: true -system_enable_docker_prune: false - -# Deployment Paths -deploy_user_home: "/home/deploy" -stacks_base_path: "{{ deploy_user_home }}/deployment/stacks" -app_stack_path: "{{ stacks_base_path }}/application" -backups_path: "{{ deploy_user_home }}/deployment/backups" - -# Docker Registry -docker_registry: "localhost:5000" -docker_registry_url: "localhost:5000" -docker_registry_external: "registry.michaelschiemer.de" -docker_registry_username_default: "admin" -# docker_registry_password_default should be set in vault as vault_docker_registry_password -# If not using vault, override via -e docker_registry_password_default="your-password" -docker_registry_password_default: "" -registry_auth_path: "{{ stacks_base_path }}/registry/auth" - -# Application Configuration -app_name: "framework" -app_domain: "michaelschiemer.de" -app_image: "{{ docker_registry }}/{{ app_name }}" -app_image_external: "{{ docker_registry_external }}/{{ app_name }}" - -# Domain Configuration -gitea_domain: "git.michaelschiemer.de" - -# Email Configuration -mail_from_address: "noreply@{{ app_domain }}" -acme_email: "kontakt@{{ app_domain }}" - -# SSL Certificate Domains -ssl_domains: - - "{{ gitea_domain }}" - - "{{ app_domain }}" - -# Health Check Configuration -health_check_url: "https://{{ app_domain }}/health" -health_check_retries: 10 -health_check_delay: 10 - -# Rollback Configuration -max_rollback_versions: 5 -rollback_timeout: 300 - -# Wait Timeouts -wait_timeout: 60 - -# Git Configuration (for sync-code.yml) -git_repository_url_default: "https://{{ gitea_domain }}/michael/michaelschiemer.git" -git_branch_default: "main" -git_token: "{{ vault_git_token | default('') }}" -git_username: "{{ vault_git_username | default('') }}" -git_password: "{{ vault_git_password | default('') }}" - -# Database Configuration -db_user_default: "postgres" -db_name_default: "michaelschiemer" - -# MinIO Object Storage Configuration -minio_root_user: "{{ vault_minio_root_user | default('minioadmin') }}" -minio_root_password: "{{ vault_minio_root_password | default('') }}" -minio_api_domain: "minio-api.michaelschiemer.de" -minio_console_domain: "minio.michaelschiemer.de" - -# WireGuard Configuration -wireguard_interface: "wg0" -wireguard_config_path: "/etc/wireguard" -wireguard_port_default: 51820 -wireguard_network_default: "10.8.0.0/24" -wireguard_server_ip_default: "10.8.0.1" -wireguard_enable_ip_forwarding: true -wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.conf" -wireguard_private_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_private.key" -wireguard_public_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_public.key" -wireguard_client_configs_path: "{{ wireguard_config_path }}/clients" - -# WireGuard DNS Configuration -# DNS server for VPN clients (points to VPN server IP) -# This ensures internal services are resolved to VPN IPs -wireguard_dns_servers: - - "{{ wireguard_server_ip_default }}" diff --git a/deployment/ansible/playbooks/deploy-application-code.yml b/deployment/ansible/playbooks/deploy-application-code.yml new file mode 100644 index 00000000..1ba8ca45 --- /dev/null +++ b/deployment/ansible/playbooks/deploy-application-code.yml @@ -0,0 +1,123 @@ +--- +- name: Deploy Application Code via Git + hosts: "{{ deployment_hosts | default('production') }}" + gather_facts: yes + become: no + + vars: + application_code_dest: "/home/deploy/michaelschiemer/current" + git_repository_url: "{{ git_repository_url | default('https://git.michaelschiemer.de/michael/michaelschiemer.git') }}" + # Determine branch based on environment + git_branch: >- + {%- if deployment_environment == 'staging' -%} + {{ git_branch | default('staging') }} + {%- else -%} + {{ git_branch | default('main') }} + {%- endif -%} + git_token: "{{ git_token | default('') }}" + # Deployment environment (staging or production) + deployment_environment: "{{ deployment_environment | default('production') }}" + + tasks: + - name: Ensure Git is installed + ansible.builtin.apt: + name: git + state: present + update_cache: no + become: yes + + - name: Ensure application code directory exists + file: + path: "{{ application_code_dest }}" + state: directory + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: '0755' + become: yes + + - name: Check if repository already exists + stat: + path: "{{ application_code_dest }}/.git" + register: git_repo_exists + + - name: Clone repository (if not exists) + ansible.builtin.git: + repo: "{{ git_repository_url }}" + dest: "{{ application_code_dest }}" + version: "{{ git_branch }}" + force: no + update: no + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + when: not git_repo_exists.stat.exists + environment: + GIT_TERMINAL_PROMPT: "0" + vars: + ansible_become: no + + - name: Update repository (if exists) + ansible.builtin.git: + repo: "{{ git_repository_url }}" + dest: "{{ application_code_dest }}" + version: "{{ git_branch }}" + force: yes + update: yes + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + when: git_repo_exists.stat.exists + environment: + GIT_TERMINAL_PROMPT: "0" + vars: + ansible_become: no + + - name: Ensure executable permissions on PHP scripts + file: + path: "{{ application_code_dest }}/{{ item }}" + mode: '0755' + loop: + - worker.php + - console.php + ignore_errors: yes + + - name: Verify worker.php exists + stat: + path: "{{ application_code_dest }}/worker.php" + register: worker_php_stat + + - name: Verify console.php exists + stat: + path: "{{ application_code_dest }}/console.php" + register: console_php_stat + + - name: Verify composer.json exists + stat: + path: "{{ application_code_dest }}/composer.json" + register: composer_json_stat + + - name: Get current Git commit hash + shell: | + cd {{ application_code_dest }} && git rev-parse HEAD + register: git_commit_hash + changed_when: false + when: git_repo_exists.stat.exists + + - name: Display file verification results + debug: + msg: | + File Verification: + - worker.php: {{ 'EXISTS' if worker_php_stat.stat.exists else 'MISSING' }} + - console.php: {{ 'EXISTS' if console_php_stat.stat.exists else 'MISSING' }} + - composer.json: {{ 'EXISTS' if composer_json_stat.stat.exists else 'MISSING' }} + - Git Branch: {{ git_branch }} + - Git Commit: {{ git_commit_hash.stdout | default('N/A') }} + + - name: Fail if critical files are missing + fail: + msg: | + Critical files are missing after Git deployment: + {% if not worker_php_stat.stat.exists %}- worker.php{% endif %} + {% if not console_php_stat.stat.exists %}- console.php{% endif %} + {% if not composer_json_stat.stat.exists %}- composer.json{% endif %} + when: + - not worker_php_stat.stat.exists or not console_php_stat.stat.exists or not composer_json_stat.stat.exists + diff --git a/deployment/ansible/playbooks/deploy-image.yml b/deployment/ansible/playbooks/deploy-image.yml new file mode 100644 index 00000000..f5c1eaad --- /dev/null +++ b/deployment/ansible/playbooks/deploy-image.yml @@ -0,0 +1,142 @@ +--- +- name: Deploy Docker Image to Application Stack + hosts: "{{ deployment_hosts | default('production') }}" + gather_facts: yes + become: no + + vars: + # Determine stack path based on environment + application_stack_dest: >- + {%- if deployment_environment == 'staging' -%} + {{ staging_stack_path | default(stacks_base_path + '/staging') }} + {%- else -%} + {{ app_stack_path | default(stacks_base_path + '/production') }} + {%- endif -%} + application_compose_suffix: >- + {%- if deployment_environment == 'staging' -%} + staging.yml + {%- else -%} + production.yml + {%- endif -%} + # Image to deploy (can be overridden via -e image_tag=...) + image_tag: "{{ image_tag | default('latest') }}" + docker_registry: "{{ docker_registry | default('registry.michaelschiemer.de') }}" + app_name: "{{ app_name | default('framework') }}" + # Full image URL + deploy_image: "{{ docker_registry }}/{{ app_name }}:{{ image_tag }}" + # Deployment environment (staging or production) + deployment_environment: "{{ deployment_environment | default('production') }}" + + tasks: + - name: Determine Docker registry password from vault or extra vars + ansible.builtin.set_fact: + registry_password: >- + {%- if docker_registry_password is defined and docker_registry_password | string | trim != '' -%} + {{ docker_registry_password }} + {%- elif vault_docker_registry_password is defined and vault_docker_registry_password | string | trim != '' -%} + {{ vault_docker_registry_password }} + {%- else -%} + {{ '' }} + {%- endif -%} + no_log: yes + + - name: Check if registry is accessible + ansible.builtin.uri: + url: "http://{{ docker_registry }}/v2/" + method: GET + status_code: [200, 401] + timeout: 5 + register: registry_check + ignore_errors: yes + delegate_to: "{{ inventory_hostname }}" + become: no + + - name: Login to Docker registry + community.docker.docker_login: + registry_url: "{{ docker_registry }}" + username: "{{ docker_registry_username | default('admin') }}" + password: "{{ registry_password }}" + when: + - registry_password | string | trim != '' + - registry_check.status | default(0) in [200, 401] + no_log: yes + ignore_errors: yes + register: docker_login_result + + - name: Pull Docker image + community.docker.docker_image: + name: "{{ deploy_image }}" + source: pull + pull: true + register: image_pull_result + failed_when: image_pull_result.failed | default(false) + + - name: Verify image exists locally + community.docker.docker_image_info: + name: "{{ deploy_image }}" + register: image_info + failed_when: image_info.failed | default(false) + + - name: Update docker-compose file with new image tag + ansible.builtin.replace: + path: "{{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }}" + regexp: '^(\s+image:\s+)({{ docker_registry }}/{{ app_name }}:)(.*)$' + replace: '\1\2{{ image_tag }}' + register: compose_update_result + failed_when: false + changed_when: compose_update_result.changed | default(false) + + - name: Update docker-compose file with new image (alternative pattern) + ansible.builtin.replace: + path: "{{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }}" + regexp: 'image:\s+{{ docker_registry }}/{{ app_name }}:.*' + replace: 'image: {{ deploy_image }}' + register: compose_update_alt + when: compose_update_result.changed == false + failed_when: false + changed_when: compose_update_alt.changed | default(false) + + - name: Ensure Docker networks exist + community.docker.docker_network: + name: "{{ item }}" + state: present + loop: + - traefik-public + - app-internal + ignore_errors: yes + + - name: Deploy application stack with new image + shell: | + cd {{ application_stack_dest }} + docker compose -f docker-compose.base.yml -f docker-compose.{{ application_compose_suffix }} up -d --pull missing --force-recreate --remove-orphans + register: compose_deploy_result + changed_when: true + + - name: Wait for containers to start + ansible.builtin.pause: + seconds: 15 + + - name: Check container status + shell: | + cd {{ application_stack_dest }} + docker compose -f docker-compose.base.yml -f docker-compose.{{ application_compose_suffix }} ps + register: container_status + changed_when: false + + - name: Display deployment summary + ansible.builtin.debug: + msg: | + ======================================== + Image Deployment Summary + ======================================== + Image: {{ deploy_image }} + Tag: {{ image_tag }} + Environment: {{ deployment_environment }} + Stack: {{ application_stack_dest }} + Status: SUCCESS + ======================================== + + Container Status: + {{ container_status.stdout | default('Not available') }} + ======================================== + diff --git a/deployment/ansible/playbooks/install-composer-dependencies.yml b/deployment/ansible/playbooks/install-composer-dependencies.yml new file mode 100644 index 00000000..5de5e8fd --- /dev/null +++ b/deployment/ansible/playbooks/install-composer-dependencies.yml @@ -0,0 +1,78 @@ +--- +- name: Install Composer Dependencies in Application Container + hosts: "{{ deployment_hosts | default('production') }}" + gather_facts: no + become: no + + vars: + # Determine stack path based on environment + application_stack_dest: >- + {%- if deployment_environment == 'staging' -%} + {{ staging_stack_path | default(stacks_base_path + '/staging') }} + {%- else -%} + {{ app_stack_path | default(stacks_base_path + '/production') }} + {%- endif -%} + application_compose_suffix: >- + {%- if deployment_environment == 'staging' -%} + staging.yml + {%- else -%} + production.yml + {%- endif -%} + # Deployment environment (staging or production) + deployment_environment: "{{ deployment_environment | default('production') }}" + # Service name (php for production, staging-app for staging) + php_service_name: >- + {%- if deployment_environment == 'staging' -%} + staging-app + {%- else -%} + php + {%- endif -%} + + tasks: + - name: Check if composer.json exists + stat: + path: /home/deploy/michaelschiemer/current/composer.json + delegate_to: "{{ inventory_hostname }}" + register: composer_json_exists + + - name: Fail if composer.json is missing + fail: + msg: "composer.json not found at /home/deploy/michaelschiemer/current/composer.json" + when: not composer_json_exists.stat.exists + + - name: Install composer dependencies in PHP container + shell: | + docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} exec -T {{ php_service_name }} composer install --no-dev --optimize-autoloader --no-interaction + register: composer_install + changed_when: true + failed_when: composer_install.rc != 0 + + - name: Restart queue-worker and scheduler to pick up vendor directory (production only) + shell: | + docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} restart queue-worker scheduler + register: restart_workers + changed_when: true + failed_when: false + when: deployment_environment == 'production' + + - name: Display composer install output + debug: + msg: | + Composer Install Output: + {{ composer_install.stdout }} + + - name: Verify vendor/autoload.php exists + shell: | + docker compose -f {{ application_stack_dest }}/docker-compose.base.yml -f {{ application_stack_dest }}/docker-compose.{{ application_compose_suffix }} exec -T {{ php_service_name }} test -f /var/www/html/vendor/autoload.php && echo "EXISTS" || echo "MISSING" + register: autoload_check + changed_when: false + + - name: Display autoload verification + debug: + msg: "vendor/autoload.php: {{ autoload_check.stdout.strip() }}" + + - name: Fail if autoload.php is missing + fail: + msg: "vendor/autoload.php was not created after composer install" + when: "autoload_check.stdout.strip() != 'EXISTS'" + diff --git a/src/Framework/Console/Layout/Components/StatusType.php b/src/Framework/Console/Layout/Components/StatusType.php new file mode 100644 index 00000000..d9c50783 --- /dev/null +++ b/src/Framework/Console/Layout/Components/StatusType.php @@ -0,0 +1,16 @@ +

500 Internal Server Error

'; - } -} diff --git a/src/Framework/ExceptionHandling/ShutdownHandlerInterface.php b/src/Framework/ExceptionHandling/ShutdownHandlerInterface.php new file mode 100644 index 00000000..924d2549 --- /dev/null +++ b/src/Framework/ExceptionHandling/ShutdownHandlerInterface.php @@ -0,0 +1,8 @@ +