From 9b204fe91accc1eb424354123bf2f04113cba9ed Mon Sep 17 00:00:00 2001 From: infinicaretech Date: Wed, 1 Apr 2026 20:26:42 +0000 Subject: [PATCH] feat: initial health-app with Express API and GitOps CI/CD Node.js Express app with /health, /ready, / endpoints. Multi-stage Dockerfile, GitHub Actions pipeline for GHCR push and gitops-infra manifest auto-update. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dockerignore | 8 ++++ .github/workflows/ci-cd.yaml | 83 ++++++++++++++++++++++++++++++++++++ .gitignore | 7 +++ Dockerfile | 27 ++++++++++++ package.json | 17 ++++++++ src/server.js | 52 ++++++++++++++++++++++ 6 files changed, 194 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci-cd.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 src/server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bf320d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.* +.github diff --git a/.github/workflows/ci-cd.yaml b/.github/workflows/ci-cd.yaml new file mode 100644 index 0000000..988aa29 --- /dev/null +++ b/.github/workflows/ci-cd.yaml @@ -0,0 +1,83 @@ +name: CI/CD - Build, Push & Deploy + +on: + push: + branches: [main] + paths: + - "src/**" + - "Dockerfile" + - "package.json" + - "package-lock.json" + - ".github/workflows/ci-cd.yaml" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/health-app + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build & Push Docker Image + runs-on: ubuntu-latest + outputs: + short_sha: ${{ steps.sha.outputs.short }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get short SHA + id: sha + run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + update-manifest: + name: Update GitOps Manifest + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: Checkout gitops-infra repo + uses: actions/checkout@v4 + with: + repository: infinicaretech/gitops-infra + token: ${{ secrets.GITOPS_PAT }} + + - name: Update image tag in kustomization + run: | + cd environments/health-app/overlays/production + sed -i "s/newTag: .*/newTag: ${{ needs.build-and-push.outputs.short_sha }}/" kustomization.yaml + echo "Updated image tag to: ${{ needs.build-and-push.outputs.short_sha }}" + cat kustomization.yaml + + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add environments/health-app/overlays/production/kustomization.yaml + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore(health-app): update image to ${{ needs.build-and-push.outputs.short_sha }}" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e857af --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +.env.* +*.log +.DS_Store +coverage/ +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee0b7f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Stage 1: Install dependencies +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Stage 2: Production image +FROM node:20-alpine AS runtime +ENV NODE_ENV=production +WORKDIR /app + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +COPY --from=deps /app/node_modules ./node_modules +COPY src ./src +COPY package.json ./ + +RUN chown -R appuser:appgroup /app +USER appuser + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:3000/health || exit 1 + +CMD ["node", "src/server.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..6587130 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@infinicaretech/health-app", + "version": "1.0.0", + "private": true, + "description": "Production-ready health check API for GitOps deployment", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "express": "^4.21.2" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..47335fb --- /dev/null +++ b/src/server.js @@ -0,0 +1,52 @@ +"use strict"; + +const express = require("express"); + +const app = express(); +const PORT = parseInt(process.env.PORT, 10) || 3000; +const startTime = Date.now(); + +app.disable("x-powered-by"); + +app.get("/", (_req, res) => { + res.json({ + service: "health-app", + version: process.env.APP_VERSION || "1.0.0", + environment: process.env.NODE_ENV || "development", + }); +}); + +app.get("/health", (_req, res) => { + res.json({ + status: "healthy", + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + memoryUsage: process.memoryUsage().rss, + }); +}); + +app.get("/ready", (_req, res) => { + res.json({ ready: true }); +}); + +app.use((_req, res) => { + res.status(404).json({ error: "Not Found" }); +}); + +app.use((err, _req, res, _next) => { + console.error("Unhandled error:", err.message); + res.status(500).json({ error: "Internal Server Error" }); +}); + +const server = app.listen(PORT, "0.0.0.0", () => { + console.log(`health-app listening on port ${PORT}`); +}); + +function shutdown(signal) { + console.log(`${signal} received, shutting down gracefully`); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 10000); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT"));