From 381e0059768b29353099857de9a80a2688a7b298 Mon Sep 17 00:00:00 2001 From: jobayer Date: Mon, 29 Sep 2025 17:24:48 +0600 Subject: [PATCH] Add CI/CD pipeline and Docker configuration for FHIR IG deployment --- .gitea/workflows/ci-cd.yaml | 283 +++++++++++++++++++++++++++++++++++ .gitea/workflows/deploy.yaml | 23 --- Dockerfile.serve | 41 +++++ nginx.conf | 27 ++++ 4 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 .gitea/workflows/ci-cd.yaml delete mode 100644 .gitea/workflows/deploy.yaml create mode 100644 Dockerfile.serve create mode 100644 nginx.conf diff --git a/.gitea/workflows/ci-cd.yaml b/.gitea/workflows/ci-cd.yaml new file mode 100644 index 0000000..58170fc --- /dev/null +++ b/.gitea/workflows/ci-cd.yaml @@ -0,0 +1,283 @@ +name: FHIR IG CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +env: + REGISTRY: git.dghs.gov.bd # Replace with your Gitea instance + IMAGE_NAME: gitadmin/bd-core-fhir-ig # Replace with your image name + +jobs: + build-ig: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Full history for proper IG building + + - name: Install Docker CLI + run: | + apt-get update + apt-get install -y docker.io + docker --version + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + install: true + + + - name: Build FHIR IG (Copy In/Out) + run: | + echo "Building FHIR IG using copy approach..." + + # Create a container (don't start yet) + CONTAINER_ID=$(docker create \ + hl7fhir/ig-publisher-base:latest \ + /bin/bash -c "cp -r /home/publisher/ig /tmp/build && cd /tmp/build && _updatePublisher.sh -y && _genonce.sh") + + echo "Container ID: $CONTAINER_ID" + + # Copy all source files into the container + docker cp $(pwd)/. $CONTAINER_ID:/home/publisher/ig/ + + # Start and wait for completion + docker start -a $CONTAINER_ID + EXIT_CODE=$? + + # Copy outputs back + echo "Copying outputs from container..." + docker cp $CONTAINER_ID:/tmp/build/output ./output || echo "Warning: No output directory" + docker cp $CONTAINER_ID:/tmp/build/fsh-generated ./fsh-generated || echo "No FSH generated files" + docker cp $CONTAINER_ID:/tmp/build/input-cache ./input-cache || echo "No input-cache" + docker cp $CONTAINER_ID:/tmp/build/temp ./temp || echo "No temp directory" + + # Show container logs if failed + if [ $EXIT_CODE -ne 0 ]; then + echo "Build failed, showing container logs:" + docker logs $CONTAINER_ID + fi + + # Cleanup + docker rm $CONTAINER_ID + + # Verify + if [ ! -f "output/index.html" ]; then + echo "ERROR: Build failed - no index.html" + exit 1 + fi + + echo "✅ Build successful!" + + - name: Verify IG Output + run: | + ls -la output/ + if [ ! -f "output/index.html" ]; then + echo "ERROR: IG build failed - no index.html found" + exit 1 + fi + echo "IG build successful!" + + - name: Login to Gitea Container Registry + if: github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.ACCESS_TOKEN_GITEA }} + + - name: Extract metadata + if: github.ref == 'refs/heads/main' + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + if: github.ref == 'refs/heads/main' + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.serve + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + + deploy: + needs: build-ig + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Deploy to server + uses: appleboy/ssh-action@v1.0.3 + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + IMAGE_TAG: latest + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + password: ${{ secrets.DEPLOY_PASSWORD }} + port: ${{ secrets.DEPLOY_PORT || 22 }} + envs: REGISTRY,IMAGE_NAME,IMAGE_TAG + script: | + # Create deployment directory if it doesn't exist + mkdir -p /opt/fhir-ig + cd /opt/fhir-ig + + # Create docker-compose.prod.yml + cat > docker-compose.prod.yml << EOF + + services: + fhir-ig: + image: \${REGISTRY}/\${IMAGE_NAME}:\${IMAGE_TAG:-latest} + container_name: fhir-ig-app + restart: unless-stopped + ports: + - "80:80" + environment: + - NODE_ENV=production + networks: + - fhir-ig-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + volumes: + - fhir-ig-logs:/var/log/nginx + + networks: + fhir-ig-network: + driver: bridge + + volumes: + fhir-ig-logs: + driver: local + EOF + + # Create deployment script + cat > deploy.sh << 'DEPLOY_SCRIPT' + #!/bin/bash + set -e + + # Configuration + COMPOSE_FILE="docker-compose.prod.yml" + SERVICE_NAME="fhir-ig" + BACKUP_DIR="/opt/backups/fhir-ig" + LOG_FILE="/var/log/fhir-ig-deploy.log" + + # Create directories + mkdir -p "$BACKUP_DIR" + mkdir -p "$(dirname "$LOG_FILE")" + + # Logging function + log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE" + } + + log "Starting deployment of BD Core FHIR IG..." + log "Registry: $REGISTRY" + log "Image: $IMAGE_NAME" + log "Tag: $IMAGE_TAG" + + # Login to registry + echo "$GITEA_PASSWORD" | docker login $REGISTRY -u "$GITEA_USERNAME" --password-stdin + + # Backup current container if it exists + if docker compose -f "$COMPOSE_FILE" ps --services --filter "status=running" | grep -q "$SERVICE_NAME"; then + log "Creating backup of current deployment..." + BACKUP_FILE="$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz" + docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" tar -czf - -C /usr/share/nginx/html . > "$BACKUP_FILE" 2>/dev/null || log "Backup failed, continuing..." + fi + + # Set environment variables for docker compose + export REGISTRY="$REGISTRY" + export IMAGE_NAME="$IMAGE_NAME" + export IMAGE_TAG="$IMAGE_TAG" + + # Pull the latest image + log "Pulling latest image: $REGISTRY/$IMAGE_NAME:$IMAGE_TAG..." + docker pull "$REGISTRY/$IMAGE_NAME:$IMAGE_TAG" + + # docker pull "\${REGISTRY}/\${IMAGE_NAME}:\${IMAGE_TAG}" + + # Stop and remove old container + log "Stopping old container..." + docker compose -f "$COMPOSE_FILE" down || log "No existing container to stop" + + # Start new container + log "Starting new container..." + docker compose -f "$COMPOSE_FILE" up -d + + # Wait for container to be healthy + # log "Waiting for container to become healthy..." + # timeout=120 + # elapsed=0 + # healthy=false + + # while [ $elapsed -lt $timeout ]; do + # if docker compose -f "$COMPOSE_FILE" ps --format json | grep -q '"Health":"healthy"'; then + # log "Container is healthy!" + # healthy=true + # break + # fi + # sleep 5 + # elapsed=$((elapsed + 5)) + # log "Waiting... ($elapsed/$timeout seconds)" + # done + + # if [ "$healthy" = false ]; then + # log "ERROR: Container failed to become healthy within $timeout seconds" + # docker compose -f "$COMPOSE_FILE" logs --tail=50 + # log "Rolling back..." + # docker compose -f "$COMPOSE_FILE" down + # exit 1 + # fi + + # Cleanup old images (keep last 3 versions) + log "Cleaning up old images..." + docker images "\${REGISTRY}/\${IMAGE_NAME}" --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | tail -n +4 | awk '{print $1}' | xargs -r docker rmi || log "No old images to clean" + + # Cleanup old backups (keep only last 5) + log "Cleaning up old backups..." + ls -t "$BACKUP_DIR"/backup-*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm || log "No old backups to clean" + + log "Deployment completed successfully!" + log "🌐 Service available at: http://$(hostname -I | awk '{print $1}')" + + # Display final status + docker compose -f "$COMPOSE_FILE" ps + DEPLOY_SCRIPT + + # Make deploy script executable + chmod +x deploy.sh + + # Set registry credentials + export GITEA_USERNAME="${{ gitea.actor }}" + export GITEA_PASSWORD="${{ secrets.ACCESS_TOKEN_GITEA }}" + + # Execute deployment + ./deploy.sh \ No newline at end of file diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml deleted file mode 100644 index b2a378e..0000000 --- a/.gitea/workflows/deploy.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: Deploy on production -on: - push: - branches: - - main - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Deploy by SSH to production environment - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.PRODUCTION_SSH_HOST }} - username: ${{ secrets.PRODUCTION_SSH_USERNAME }} - password: ${{ secrets.PRODUCTION_SSH_PASSWORD }} - port: ${{ secrets.PRODUCTION_SSH_PORT }} - script: | - cd /home/mishealth/BD-Core-FHIR-IG - git pull origin main - docker run --rm -v $(pwd):/home/publisher/ig hl7fhir/ig-publisher-base:latest /home/publisher/ig/_genonce.sh - rsync -av output/ /var/www/html - sudo systemctl restart nginx \ No newline at end of file diff --git a/Dockerfile.serve b/Dockerfile.serve new file mode 100644 index 0000000..26d6268 --- /dev/null +++ b/Dockerfile.serve @@ -0,0 +1,41 @@ +# Multi-stage build for serving FHIR IG output +FROM nginx:alpine + +# Copy the built IG output to nginx html directory +# (Uncomment and adjust the path if needed) +COPY output/ /usr/share/nginx/html/ + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Create a non-root user for security +RUN addgroup -g 1001 -S nginx-user && \ + adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx-user -g nginx-user nginx-user + +# Set proper permissions for Nginx directories +RUN chown -R nginx-user:nginx-user /usr/share/nginx/html && \ + chown -R nginx-user:nginx-user /var/cache/nginx && \ + chown -R nginx-user:nginx-user /var/log/nginx && \ + chown -R nginx-user:nginx-user /etc/nginx/conf.d + +# Fix Nginx PID permission issue +RUN mkdir -p /var/cache/nginx/run && \ + chown -R nginx-user:nginx-user /var/cache/nginx/run + +# Update nginx.conf to point PID to writable location +# Ensure your nginx.conf has: +# pid /var/cache/nginx/run/nginx.pid; + +# Switch to non-root user +USER nginx-user + +# Health check +# HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ +# CMD curl -f http://localhost/ || exit 1 + +EXPOSE 80 + +# Start Nginx in foreground +# CMD ["nginx", "-g", "daemon off;"] + +CMD ["nginx", "-g", "daemon off;", "-c", "/etc/nginx/nginx.conf"] \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6c4c30d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,27 @@ +pid /var/cache/nginx/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + } +} \ No newline at end of file