From 1e8d7db8e643d350d800f208ba372a53f65db7b5 Mon Sep 17 00:00:00 2001 From: khalil-bot Date: Thu, 28 May 2026 15:56:28 +0200 Subject: [PATCH] ci(notification-service): add CI/CD pipeline with Docker, SSH deploy and API credentials Add GitHub Actions workflow: Maven test on every push, Docker build & push to GHCR on main, SSH deploy with runtime injection of TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, AIR_QUALITY_API_URL, API_USERNAME and API_PASSWORD secrets. Add Basic Auth support in AirQualityService using API_USERNAME/API_PASSWORD env vars. Closes #19 --- .github/workflows/notification-service.yml | 122 ++++++++++++++++++ .../config/NotificationProperties.java | 16 ++- .../service/AirQualityService.java | 18 ++- .../src/main/resources/application.yml | 2 + 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/notification-service.yml diff --git a/.github/workflows/notification-service.yml b/.github/workflows/notification-service.yml new file mode 100644 index 0000000..c211cda --- /dev/null +++ b/.github/workflows/notification-service.yml @@ -0,0 +1,122 @@ +name: Notification Service – CI/CD + +on: + workflow_dispatch: + push: + paths: + - notification_service/** + - .github/workflows/notification-service.yml + pull_request: + branches: [main] + paths: + - notification_service/** + - .github/workflows/notification-service.yml + +jobs: + # ── 1. Build & test ────────────────────────────────────────────────────────── + ci: + name: Build & test + runs-on: ubuntu-latest + defaults: + run: + working-directory: notification_service + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + cache: maven + + - name: Run tests + run: mvn verify -q + + # ── 2. Docker build & push ─────────────────────────────────────────────────── + docker: + name: Docker build & push + runs-on: ubuntu-latest + needs: ci + if: github.ref == 'refs/heads/main' + outputs: + sha_tag: ${{ steps.tag.outputs.sha }} + + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Compute image tags + id: tag + run: | + OWNER="${{ github.repository_owner }}" + IMAGE_LC="ghcr.io/${OWNER,,}/notification-service" + echo "image=${IMAGE_LC}" >> $GITHUB_OUTPUT + echo "sha=${IMAGE_LC}:${{ github.sha }}" >> $GITHUB_OUTPUT + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "extra=${IMAGE_LC}:latest" >> $GITHUB_OUTPUT + else + BRANCH="${{ github.head_ref || github.ref_name }}" + echo "extra=${IMAGE_LC}:branch-${BRANCH//\//-}" >> $GITHUB_OUTPUT + fi + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push + uses: docker/build-push-action@v5 + with: + context: notification_service + push: true + tags: | + ${{ steps.tag.outputs.sha }} + ${{ steps.tag.outputs.extra }} + + # ── 3. Deploy to physical server (SSH) ─────────────────────────────────────── + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: docker + if: github.ref == 'refs/heads/main' + + steps: + - name: SSH deploy + env: + SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_CERT: ${{ secrets.SSH_CERTIFICATE }} + SHA_TAG: ${{ needs.docker.outputs.sha_tag }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + AIR_QUALITY_API_URL: ${{ secrets.AIR_QUALITY_API_URL }} + API_USERNAME: ${{ secrets.API_USERNAME }} + API_PASSWORD: ${{ secrets.API_PASSWORD }} + run: | + echo "$SSH_KEY" > /tmp/deploy_key + echo "$SSH_CERT" > /tmp/deploy_key-cert.pub + chmod 600 /tmp/deploy_key + ssh -i /tmp/deploy_key \ + -p ${{ secrets.SSH_PORT }} \ + -o StrictHostKeyChecking=no \ + -o IdentitiesOnly=yes \ + ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \ + "sudo -iu e << 'EOF' + docker pull $SHA_TAG + docker stop notification-service 2>/dev/null || true + docker rm notification-service 2>/dev/null || true + docker run -d --name notification-service --restart unless-stopped \ + -e TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN \ + -e TELEGRAM_CHAT_ID=$TELEGRAM_CHAT_ID \ + -e AIR_QUALITY_API_URL=$AIR_QUALITY_API_URL \ + -e API_USERNAME=$API_USERNAME \ + -e API_PASSWORD=$API_PASSWORD \ + $SHA_TAG + EOF" + rm /tmp/deploy_key /tmp/deploy_key-cert.pub diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java index 0d1f761..8c98020 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java @@ -28,15 +28,23 @@ public class NotificationProperties { } public static class AirQuality { - private String apiUrl = "http://localhost:8080"; - private long pollIntervalMs = 60_000; - private Thresholds thresholds = new Thresholds(); + private String apiUrl = "http://localhost:8080"; + private String apiUsername; + private String apiPassword; + private long pollIntervalMs = 60_000; + private Thresholds thresholds = new Thresholds(); private String alertFromLevel = "poor"; private boolean mockMode = false; - public String getApiUrl() { return apiUrl; } + public String getApiUrl() { return apiUrl; } public void setApiUrl(String apiUrl) { this.apiUrl = apiUrl; } + public String getApiUsername() { return apiUsername; } + public void setApiUsername(String apiUsername) { this.apiUsername = apiUsername; } + + public String getApiPassword() { return apiPassword; } + public void setApiPassword(String apiPassword) { this.apiPassword = apiPassword; } + public long getPollIntervalMs() { return pollIntervalMs; } public void setPollIntervalMs(long pollIntervalMs) { this.pollIntervalMs = pollIntervalMs; } diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java index 8b56100..dbdec71 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java @@ -6,10 +6,13 @@ import ch.hesso.pi.notification.model.SensorReading; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import java.util.Base64; import java.util.List; @Service @@ -30,10 +33,19 @@ public class AirQualityService { * Returns an empty list on error so the scheduler can continue safely. */ public List fetchLatestReadings() { - String url = props.getAirQuality().getApiUrl() + "/sensors/latest"; + String url = props.getAirQuality().getApiUrl() + "/sensors/latest"; + String username = props.getAirQuality().getApiUsername(); + String password = props.getAirQuality().getApiPassword(); + try { - List readings = restClient.get() - .uri(url) + var request = restClient.get().uri(url); + + if (StringUtils.hasText(username) && StringUtils.hasText(password)) { + String encoded = Base64.getEncoder().encodeToString((username + ":" + password).getBytes()); + request = request.header(HttpHeaders.AUTHORIZATION, "Basic " + encoded); + } + + List readings = request .retrieve() .body(new ParameterizedTypeReference<>() {}); return readings != null ? readings : List.of(); diff --git a/notification_service/src/main/resources/application.yml b/notification_service/src/main/resources/application.yml index 66c7e93..85ace92 100644 --- a/notification_service/src/main/resources/application.yml +++ b/notification_service/src/main/resources/application.yml @@ -17,6 +17,8 @@ notification: chat-id: ${TELEGRAM_CHAT_ID} air-quality: api-url: ${AIR_QUALITY_API_URL:http://localhost:8080} + api-username: ${API_USERNAME:} + api-password: ${API_PASSWORD:} poll-interval-ms: ${POLL_INTERVAL_MS:60000} # Levels matching the UI co2-levels.config.ts thresholds thresholds: