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
|
||||||
@@ -29,6 +29,8 @@ public class NotificationProperties {
|
|||||||
|
|
||||||
public static class AirQuality {
|
public static class AirQuality {
|
||||||
private String apiUrl = "http://localhost:8080";
|
private String apiUrl = "http://localhost:8080";
|
||||||
|
private String apiUsername;
|
||||||
|
private String apiPassword;
|
||||||
private long pollIntervalMs = 60_000;
|
private long pollIntervalMs = 60_000;
|
||||||
private Thresholds thresholds = new Thresholds();
|
private Thresholds thresholds = new Thresholds();
|
||||||
private String alertFromLevel = "poor";
|
private String alertFromLevel = "poor";
|
||||||
@@ -37,6 +39,12 @@ public class NotificationProperties {
|
|||||||
public String getApiUrl() { return apiUrl; }
|
public String getApiUrl() { return apiUrl; }
|
||||||
public void setApiUrl(String apiUrl) { this.apiUrl = 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 long getPollIntervalMs() { return pollIntervalMs; }
|
||||||
public void setPollIntervalMs(long pollIntervalMs) { this.pollIntervalMs = 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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -31,9 +34,18 @@ public class AirQualityService {
|
|||||||
*/
|
*/
|
||||||
public List<SensorReading> fetchLatestReadings() {
|
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 {
|
try {
|
||||||
List<SensorReading> readings = restClient.get()
|
var request = restClient.get().uri(url);
|
||||||
.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()
|
.retrieve()
|
||||||
.body(new ParameterizedTypeReference<>() {});
|
.body(new ParameterizedTypeReference<>() {});
|
||||||
return readings != null ? readings : List.of();
|
return readings != null ? readings : List.of();
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ notification:
|
|||||||
chat-id: ${TELEGRAM_CHAT_ID}
|
chat-id: ${TELEGRAM_CHAT_ID}
|
||||||
air-quality:
|
air-quality:
|
||||||
api-url: ${AIR_QUALITY_API_URL:http://localhost:8080}
|
api-url: ${AIR_QUALITY_API_URL:http://localhost:8080}
|
||||||
|
api-username: ${API_USERNAME:}
|
||||||
|
api-password: ${API_PASSWORD:}
|
||||||
poll-interval-ms: ${POLL_INTERVAL_MS:60000}
|
poll-interval-ms: ${POLL_INTERVAL_MS:60000}
|
||||||
# Levels matching the UI co2-levels.config.ts thresholds
|
# Levels matching the UI co2-levels.config.ts thresholds
|
||||||
thresholds:
|
thresholds:
|
||||||
|
|||||||
Reference in New Issue
Block a user