Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
194 changes: 194 additions & 0 deletions .github/workflows/ci-kind-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
name: CI - Kind Helm PR workflow

on:
pull_request:
branches: ["master"]

permissions:
contents: read
pull-requests: write
security-events: write

concurrency:
group: pr-deploy-${{ github.event.number }}
cancel-in-progress: true

jobs:
lint-chart:
name: Helm lint + schema
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Helm
run: |
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

- name: Run helm lint
run: |
helm lint ./helm

- name: Render chart manifests
run: helm template ./helm > rendered-manifests.yaml

unit-tests:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Cache Node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-

- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm test -- --runInBand

security-scan:
name: Image security scan (Trivy)
runs-on: ubuntu-latest
needs: [lint-chart, unit-tests]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build docker image
run: |
docker build -t node-hello:${{ github.sha }} .

- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: node-hello:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '0'

- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'

- name: Run Trivy scan (table output)
uses: aquasecurity/trivy-action@master
with:
image-ref: node-hello:${{ github.sha }}
format: 'table'
severity: 'CRITICAL,HIGH'

deploy-pr:
name: Deploy to ephemeral PR namespace and validate
runs-on: ubuntu-latest
needs: [lint-chart, unit-tests, security-scan]
env:
PR_NS: pr-${{ github.event.number }}
RELEASE: pr-${{ github.event.number }}
IMAGE: node-hello:${{ github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup kind
uses: helm/kind-action@v1
with:
cluster_name: kind
wait: 120s

- name: Build Docker image
run: docker build -t $IMAGE .

- name: Load image into kind
run: |
kind load docker-image $IMAGE

- name: Install kubectl
uses: azure/setup-kubectl@v3
with:
version: 'latest'

- name: Install Helm
run: |
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

- name: Create namespace (ephemeral PR namespace)
run: kubectl create namespace $PR_NS --dry-run=client -o yaml | kubectl apply -f -

- name: Annotate namespace with TTL (60 minutes)
run: |
EXPIRY=$(date -u -d "+60 minutes" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v+60M +%Y-%m-%dT%H:%M:%SZ)
kubectl annotate namespace $PR_NS ttl.expires=$EXPIRY --overwrite

- name: Helm deploy
run: |
helm upgrade --install $RELEASE ./helm \
-n $PR_NS --create-namespace \
--wait --timeout 3m \
--set image.repository=node-hello,image.tag=${{ github.sha }} \
--set resources.limits.memory=256Mi \
--set resources.limits.cpu=200m

- name: Wait for deployments to be available
run: kubectl -n $PR_NS wait deployment --all --for=condition=available --timeout=120s

- name: Inspect pod readiness statuses
run: |
kubectl -n $PR_NS get pods -o json | jq '.items[] | {name: .metadata.name, statuses: .status.containerStatuses}'

- name: Smoke test service
run: |
kubectl -n $PR_NS run curl-test --image=curlimages/curl --restart=Never -- \
sh -c "curl -f -m 10 http://$RELEASE:3000/ && echo 'Smoke test passed'"
kubectl -n $PR_NS wait pod curl-test --for=condition=Ready --timeout=30s
kubectl -n $PR_NS logs curl-test
kubectl -n $PR_NS delete pod curl-test

- name: Comment PR with deployment info
if: success()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `✅ **Deployment Successful**\n\n` +
`- **Namespace:** \`pr-${{ github.event.number }}\`\n` +
`- **Image:** \`node-hello:${{ github.sha }}\`\n` +
`- **TTL:** Expires in 60 minutes\n` +
`- **Smoke test:** Passed ✓`
})

- name: Cleanup namespace on failure
if: failure()
run: |
echo "::error::Deployment failed. Collecting diagnostic information..."
echo "=== Pod Status ==="
kubectl -n $PR_NS get pods -o wide || true
echo "=== Pod Logs ==="
kubectl -n $PR_NS logs -l app.kubernetes.io/instance=$RELEASE --tail=100 || true
echo "=== Pod Descriptions ==="
kubectl -n $PR_NS describe pods || true
echo "=== Events ==="
kubectl -n $PR_NS get events --sort-by='.lastTimestamp' || true
kubectl delete namespace $PR_NS --wait=false || true

- name: Cleanup namespace on success
if: success()
run: |
kubectl delete namespace $PR_NS --wait || true
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Use Node.js LTS (Long Term Support) as the base image
FROM node:18-slim

# Create app directory
WORKDIR /app

# Copy package.json and package-lock.json (if exists)
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the application code
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Command to run the application
CMD [ "npm", "start" ]
6 changes: 6 additions & 0 deletions helm/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: v2
name: node-hello
description: A Helm chart for the Node.js Hello World application
type: application
version: 0.1.0
appVersion: "1.0.0"
51 changes: 51 additions & 0 deletions helm/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "node-hello.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "node-hello.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "node-hello.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "node-hello.labels" -}}
helm.sh/chart: {{ include "node-hello.chart" . }}
{{ include "node-hello.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "node-hello.selectorLabels" -}}
app.kubernetes.io/name: {{ include "node-hello.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
36 changes: 36 additions & 0 deletions helm/templates/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "node-hello.fullname" . }}
labels:
{{- include "node-hello.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "node-hello.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "node-hello.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
24 changes: 24 additions & 0 deletions helm/templates/hpa.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "node-hello.fullname" . }}
labels:
{{- include "node-hello.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "node-hello.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- end }}
43 changes: 43 additions & 0 deletions helm/templates/ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "node-hello.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "node-hello.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
Loading