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
This commit is contained in:
122
.github/workflows/notification-service.yml
vendored
Normal file
122
.github/workflows/notification-service.yml
vendored
Normal file
@@ -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
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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<SensorReading> 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<SensorReading> 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<SensorReading> readings = request
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {});
|
||||
return readings != null ? readings : List.of();
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user