feat: add Kubernetes deployment support with Helm chart

Add health endpoints (/healthz, /readyz), graceful shutdown with
SIGTERM/SIGINT handling, multi-stage Dockerfile with distroless
runtime, and a full Helm chart with security-hardened defaults.
This commit is contained in:
2026-02-10 04:45:14 +01:00
parent 80466464e6
commit 00c8b0f18c
14 changed files with 409 additions and 3 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
bin/
node_modules/
.git/
*.md
heatguard

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM golang:1.25-alpine AS build
RUN apk add --no-cache nodejs npm
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN CGO_ENABLED=0 make build
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /src/bin/heatguard /heatguard
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/heatguard"]

View File

@@ -1,10 +1,15 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/config"
"github.com/cnachtigall/heatwave-autopilot/internal/server"
@@ -32,8 +37,30 @@ func main() {
}
addr := fmt.Sprintf(":%d", *port)
log.Printf("HeatGuard listening on http://localhost%s", addr)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatal(err)
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(),
}
// Graceful shutdown on SIGTERM/SIGINT
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
log.Printf("HeatGuard listening on http://localhost%s", addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
log.Println("Shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Println("Shutdown complete")
}

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: heatguard
description: HeatGuard — heat budget dashboard for buildings
type: application
version: 0.1.0
appVersion: "1.0.0"

View File

@@ -0,0 +1,15 @@
HeatGuard has been deployed.
1. Get the application URL:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "heatguard.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "ClusterIP" .Values.service.type }}
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "heatguard.fullname" . }} 8080:{{ .Values.service.port }}
echo http://localhost:8080
{{- end }}

View File

@@ -0,0 +1,60 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "heatguard.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "heatguard.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 "heatguard.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "heatguard.labels" -}}
helm.sh/chart: {{ include "heatguard.chart" . }}
{{ include "heatguard.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "heatguard.selectorLabels" -}}
app.kubernetes.io/name: {{ include "heatguard.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use.
*/}}
{{- define "heatguard.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "heatguard.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "heatguard.fullname" . }}
labels:
{{- include "heatguard.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "heatguard.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "heatguard.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "heatguard.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 2
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,22 @@
{{- if .Values.autoscaling.enabled -}}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "heatguard.fullname" . }}
labels:
{{- include "heatguard.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "heatguard.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "heatguard.fullname" . }}
labels:
{{- include "heatguard.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: {{ include "heatguard.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "heatguard.fullname" . }}
labels:
{{- include "heatguard.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "heatguard.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "heatguard.serviceAccountName" . }}
labels:
{{- include "heatguard.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: false
{{- end }}

View File

@@ -0,0 +1,57 @@
replicaCount: 1
image:
repository: heatguard
pullPolicy: IfNotPresent
tag: ""
service:
type: ClusterIP
port: 80
targetPort: 8080
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: heatguard.local
paths:
- path: /
pathType: Prefix
tls: []
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 64Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 80
serviceAccount:
create: true
name: ""
annotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -1,12 +1,14 @@
package server
import (
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"github.com/cnachtigall/heatwave-autopilot/internal/config"
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
@@ -19,6 +21,7 @@ type Server struct {
cfg config.Config
llmProvider llm.Provider
devMode bool
ready atomic.Bool
}
// Options configures the server.
@@ -82,6 +85,10 @@ func New(opts Options) (*Server, error) {
s.mux.HandleFunc("/setup", s.handleSetup)
s.mux.HandleFunc("/guide", s.handleGuide)
// Health routes
s.mux.HandleFunc("/healthz", s.handleHealthz)
s.mux.HandleFunc("/readyz", s.handleReadyz)
// API routes
s.mux.HandleFunc("/api/compute/dashboard", s.handleComputeDashboard)
s.mux.HandleFunc("/api/weather/forecast", s.handleWeatherForecast)
@@ -90,6 +97,7 @@ func New(opts Options) (*Server, error) {
s.mux.HandleFunc("/api/llm/actions", s.handleLLMActions)
s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig)
s.ready.Store(true)
return s, nil
}
@@ -178,6 +186,21 @@ func (s *Server) handleGuide(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, r, "guide", "guide.html")
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if !s.ready.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "not ready"})
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func buildLLMProvider(cfg config.Config) llm.Provider {
switch cfg.LLM.Provider {
case "anthropic":

View File

@@ -148,6 +148,46 @@ func TestLLMConfigAPI(t *testing.T) {
}
}
func TestHealthz(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/healthz", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("got content-type %q, want application/json", ct)
}
var resp struct {
Status string `json:"status"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Status != "ok" {
t.Errorf("got status %q, want ok", resp.Status)
}
}
func TestReadyz(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/readyz", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("got content-type %q, want application/json", ct)
}
var resp struct {
Status string `json:"status"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Status != "ok" {
t.Errorf("got status %q, want ok", resp.Status)
}
}
func TestLLMSummarize_Noop(t *testing.T) {
s := testServer(t)
body := `{"date":"2025-07-15","peakTempC":35,"riskLevel":"high"}`