From 00c8b0f18c22fa323fac0982c21b67ee83411902 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 10 Feb 2026 04:45:14 +0100 Subject: [PATCH] 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. --- .dockerignore | 5 ++ Dockerfile | 25 ++++++++ cmd/heatguard/main.go | 33 ++++++++++- helm/heatguard/Chart.yaml | 6 ++ helm/heatguard/templates/NOTES.txt | 15 +++++ helm/heatguard/templates/_helpers.tpl | 60 ++++++++++++++++++++ helm/heatguard/templates/deployment.yaml | 57 +++++++++++++++++++ helm/heatguard/templates/hpa.yaml | 22 +++++++ helm/heatguard/templates/ingress.yaml | 41 +++++++++++++ helm/heatguard/templates/service.yaml | 15 +++++ helm/heatguard/templates/serviceaccount.yaml | 13 +++++ helm/heatguard/values.yaml | 57 +++++++++++++++++++ internal/server/server.go | 23 ++++++++ internal/server/server_test.go | 40 +++++++++++++ 14 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 helm/heatguard/Chart.yaml create mode 100644 helm/heatguard/templates/NOTES.txt create mode 100644 helm/heatguard/templates/_helpers.tpl create mode 100644 helm/heatguard/templates/deployment.yaml create mode 100644 helm/heatguard/templates/hpa.yaml create mode 100644 helm/heatguard/templates/ingress.yaml create mode 100644 helm/heatguard/templates/service.yaml create mode 100644 helm/heatguard/templates/serviceaccount.yaml create mode 100644 helm/heatguard/values.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b490f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +bin/ +node_modules/ +.git/ +*.md +heatguard diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ecbf5fe --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/heatguard/main.go b/cmd/heatguard/main.go index d8782c6..073ecd3 100644 --- a/cmd/heatguard/main.go +++ b/cmd/heatguard/main.go @@ -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") } diff --git a/helm/heatguard/Chart.yaml b/helm/heatguard/Chart.yaml new file mode 100644 index 0000000..35aa71b --- /dev/null +++ b/helm/heatguard/Chart.yaml @@ -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" diff --git a/helm/heatguard/templates/NOTES.txt b/helm/heatguard/templates/NOTES.txt new file mode 100644 index 0000000..7a073df --- /dev/null +++ b/helm/heatguard/templates/NOTES.txt @@ -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 }} diff --git a/helm/heatguard/templates/_helpers.tpl b/helm/heatguard/templates/_helpers.tpl new file mode 100644 index 0000000..fcf4ff9 --- /dev/null +++ b/helm/heatguard/templates/_helpers.tpl @@ -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 }} diff --git a/helm/heatguard/templates/deployment.yaml b/helm/heatguard/templates/deployment.yaml new file mode 100644 index 0000000..f7641a8 --- /dev/null +++ b/helm/heatguard/templates/deployment.yaml @@ -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 }} diff --git a/helm/heatguard/templates/hpa.yaml b/helm/heatguard/templates/hpa.yaml new file mode 100644 index 0000000..665bc3e --- /dev/null +++ b/helm/heatguard/templates/hpa.yaml @@ -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 }} diff --git a/helm/heatguard/templates/ingress.yaml b/helm/heatguard/templates/ingress.yaml new file mode 100644 index 0000000..c699c88 --- /dev/null +++ b/helm/heatguard/templates/ingress.yaml @@ -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 }} diff --git a/helm/heatguard/templates/service.yaml b/helm/heatguard/templates/service.yaml new file mode 100644 index 0000000..b51779a --- /dev/null +++ b/helm/heatguard/templates/service.yaml @@ -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 }} diff --git a/helm/heatguard/templates/serviceaccount.yaml b/helm/heatguard/templates/serviceaccount.yaml new file mode 100644 index 0000000..f859340 --- /dev/null +++ b/helm/heatguard/templates/serviceaccount.yaml @@ -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 }} diff --git a/helm/heatguard/values.yaml b/helm/heatguard/values.yaml new file mode 100644 index 0000000..03f0b95 --- /dev/null +++ b/helm/heatguard/values.yaml @@ -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: {} diff --git a/internal/server/server.go b/internal/server/server.go index bb8ad7e..42dd510 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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": diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dcb2e31..29df719 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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"}`