From 102aaf6dd26541f9854cb3eeb8e0510647c7d03c Mon Sep 17 00:00:00 2001
From: Ciaran Roche <croche@redhat.com>
Date: Mon, 8 Dec 2025 13:46:09 +0000
Subject: [PATCH 1/5] Add image Makefile targets and Helm chart
 (HYPERFLEET-312)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add image, image-push, image-dev Makefile targets for container builds
- Update Dockerfile to multi-stage build (consistent with sentinel/adapter)
- Add Helm chart with configurable PostgreSQL support:
  - Built-in PostgreSQL for development (default)
  - External database support for production (GCP Cloud SQL, etc.)
- Update README with container image and Helm deployment docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 Dockerfile                           |  36 +++++---
 Makefile                             |  45 ++++++++++
 README.md                            |  84 +++++++++++++++++++
 charts/Chart.yaml                    |  15 ++++
 charts/templates/_helpers.tpl        |  62 ++++++++++++++
 charts/templates/deployment.yaml     | 109 ++++++++++++++++++++++++
 charts/templates/hpa.yaml            |  32 +++++++
 charts/templates/postgresql.yaml     | 120 +++++++++++++++++++++++++++
 charts/templates/service.yaml        |  15 ++++
 charts/templates/serviceaccount.yaml |  12 +++
 charts/values.yaml                   | 118 ++++++++++++++++++++++++++
 11 files changed, 636 insertions(+), 12 deletions(-)
 create mode 100644 charts/Chart.yaml
 create mode 100644 charts/templates/_helpers.tpl
 create mode 100644 charts/templates/deployment.yaml
 create mode 100644 charts/templates/hpa.yaml
 create mode 100644 charts/templates/postgresql.yaml
 create mode 100644 charts/templates/service.yaml
 create mode 100644 charts/templates/serviceaccount.yaml
 create mode 100644 charts/values.yaml

diff --git a/Dockerfile b/Dockerfile
index cf119fac..a9bcb9a9 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,21 +1,33 @@
-FROM registry.access.redhat.com/ubi9/ubi-minimal:9.2-750.1697534106
+# Build stage
+FROM golang:1.24-alpine AS builder
 
-RUN \
-    microdnf install -y \
-    util-linux \
-    && \
-    microdnf clean all
+WORKDIR /build
 
-COPY \
-    hyperfleet-api \
-    /usr/local/bin/
+# Copy go mod files
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build binary
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o hyperfleet-api ./cmd/hyperfleet-api
+
+# Runtime stage
+FROM gcr.io/distroless/static-debian12:nonroot
+
+WORKDIR /app
+
+# Copy binary from builder
+COPY --from=builder /build/hyperfleet-api /app/hyperfleet-api
 
 EXPOSE 8000
 
-ENTRYPOINT ["/usr/local/bin/hyperfleet-api", "serve"]
+ENTRYPOINT ["/app/hyperfleet-api"]
+CMD ["serve"]
 
 LABEL name="hyperfleet-api" \
       vendor="Red Hat" \
       version="0.0.1" \
-      summary="HyperFleet API" \
-      description="HyperFleet API"
+      summary="HyperFleet API - Cluster Lifecycle Management Service" \
+      description="HyperFleet API for cluster lifecycle management"
diff --git a/Makefile b/Makefile
index a0695bec..bc281ee8 100755
--- a/Makefile
+++ b/Makefile
@@ -15,6 +15,16 @@ version:=$(shell date +%s)
 # a tool for managing containers and images, etc. You can set it as docker
 container_tool ?= podman
 
+# Image configuration
+IMAGE_REGISTRY ?= quay.io/openshift-hyperfleet
+IMAGE_NAME ?= hyperfleet-api
+IMAGE_TAG ?= latest
+
+# Dev image configuration - set QUAY_USER to push to personal registry
+# Usage: QUAY_USER=myuser make image-dev
+QUAY_USER ?=
+DEV_TAG ?= dev-$(git_sha)
+
 # Database connection details
 db_name:=hyperfleet
 db_host=hyperfleet-db.$(namespace)
@@ -53,6 +63,9 @@ help:
 	@echo "make generate-mocks       generate mock implementations for services"
 	@echo "make generate-all         generate all code (openapi + mocks)"
 	@echo "make clean                delete temporary generated files"
+	@echo "make image                build container image"
+	@echo "make image-push           build and push container image"
+	@echo "make image-dev            build and push to personal Quay registry"
 	@echo "$(fake)"
 .PHONY: help
 
@@ -283,3 +296,35 @@ db/login:
 db/teardown:
 	$(container_tool) stop psql-hyperfleet
 	$(container_tool) rm psql-hyperfleet
+
+# Build container image (multi-stage build, no local binary needed)
+.PHONY: image
+image:
+	@echo "Building container image $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)..."
+	$(container_tool) build -t $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) .
+	@echo "✅ Image built: $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)"
+
+# Build and push container image to registry
+.PHONY: image-push
+image-push: image
+	@echo "Pushing image $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)..."
+	$(container_tool) push $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
+	@echo "✅ Image pushed: $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)"
+
+# Build and push to personal Quay registry (requires QUAY_USER)
+.PHONY: image-dev
+image-dev:
+ifndef QUAY_USER
+	@echo "❌ ERROR: QUAY_USER is not set"
+	@echo ""
+	@echo "Usage: QUAY_USER=myuser make image-dev"
+	@echo ""
+	@echo "This will build and push to: quay.io/$$QUAY_USER/$(IMAGE_NAME):$(DEV_TAG)"
+	@exit 1
+endif
+	@echo "Building dev image quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG)..."
+	$(container_tool) build -t quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG) .
+	@echo "Pushing dev image..."
+	$(container_tool) push quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG)
+	@echo ""
+	@echo "✅ Dev image pushed: quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG)"
diff --git a/README.md b/README.md
index 3ad1d9ce..267b0733 100755
--- a/README.md
+++ b/README.md
@@ -464,6 +464,90 @@ make db/teardown   # Remove PostgreSQL container
 make db/login      # Connect to database shell
 ```
 
+## Container Image
+
+Build and push container images using the multi-stage Dockerfile:
+
+```bash
+# Build container image
+make image
+
+# Build with custom tag
+make image IMAGE_TAG=v1.0.0
+
+# Build and push to default registry
+make image-push
+
+# Build and push to personal Quay registry (for development)
+QUAY_USER=myuser make image-dev
+```
+
+Default image: `quay.io/openshift-hyperfleet/hyperfleet-api:latest`
+
+## Kubernetes Deployment
+
+### Using Helm Chart
+
+The project includes a Helm chart for Kubernetes deployment with configurable PostgreSQL support.
+
+**Development deployment (with built-in PostgreSQL):**
+```bash
+helm install hyperfleet-api ./charts/ \
+  --namespace hyperfleet-system \
+  --create-namespace
+```
+
+**Production deployment (with external database like GCP Cloud SQL):**
+```bash
+# First, create a secret with database credentials
+kubectl create secret generic hyperfleet-db-external \
+  --namespace hyperfleet-system \
+  --from-literal=db.host=<your-cloudsql-ip> \
+  --from-literal=db.port=5432 \
+  --from-literal=db.name=hyperfleet \
+  --from-literal=db.user=hyperfleet \
+  --from-literal=db.password=<your-password>
+
+# Deploy with external database
+helm install hyperfleet-api ./charts/ \
+  --namespace hyperfleet-system \
+  --set database.postgresql.enabled=false \
+  --set database.external.enabled=true \
+  --set database.external.secretName=hyperfleet-db-external
+```
+
+**Custom image deployment:**
+```bash
+helm install hyperfleet-api ./charts/ \
+  --namespace hyperfleet-system \
+  --set image.registry=quay.io/myuser \
+  --set image.repository=hyperfleet-api \
+  --set image.tag=v1.0.0
+```
+
+**Upgrade deployment:**
+```bash
+helm upgrade hyperfleet-api ./charts/ --namespace hyperfleet-system
+```
+
+**Uninstall:**
+```bash
+helm uninstall hyperfleet-api --namespace hyperfleet-system
+```
+
+### Helm Values
+
+| Parameter | Description | Default |
+|-----------|-------------|---------|
+| `image.registry` | Container registry | `quay.io/openshift-hyperfleet` |
+| `image.repository` | Image repository | `hyperfleet-api` |
+| `image.tag` | Image tag | `latest` |
+| `database.postgresql.enabled` | Deploy built-in PostgreSQL | `true` |
+| `database.external.enabled` | Use external database | `false` |
+| `database.external.secretName` | Secret with db credentials | `""` |
+| `auth.enableJwt` | Enable JWT authentication | `true` |
+| `auth.enableAuthz` | Enable authorization | `true` |
+
 ## API Authentication
 
 **Development mode (no auth):**
diff --git a/charts/Chart.yaml b/charts/Chart.yaml
new file mode 100644
index 00000000..fd9cfe32
--- /dev/null
+++ b/charts/Chart.yaml
@@ -0,0 +1,15 @@
+apiVersion: v2
+name: hyperfleet-api
+description: HyperFleet API - Cluster Lifecycle Management Service
+type: application
+version: 1.0.0
+appVersion: "1.0.0"
+maintainers:
+  - name: HyperFleet Team
+    email: hyperfleet-team@redhat.com
+keywords:
+  - hyperfleet
+  - api
+  - kubernetes
+  - cluster-management
+home: https://github.com/openshift-hyperfleet/hyperfleet-api
diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl
new file mode 100644
index 00000000..b1d4c003
--- /dev/null
+++ b/charts/templates/_helpers.tpl
@@ -0,0 +1,62 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "hyperfleet-api.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 "hyperfleet-api.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 "hyperfleet-api.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "hyperfleet-api.labels" -}}
+helm.sh/chart: {{ include "hyperfleet-api.chart" . }}
+{{ include "hyperfleet-api.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "hyperfleet-api.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "hyperfleet-api.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "hyperfleet-api.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "hyperfleet-api.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml
new file mode 100644
index 00000000..194d0df9
--- /dev/null
+++ b/charts/templates/deployment.yaml
@@ -0,0 +1,109 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+spec:
+  {{- if not .Values.autoscaling.enabled }}
+  replicas: {{ .Values.replicaCount }}
+  {{- end }}
+  selector:
+    matchLabels:
+      {{- include "hyperfleet-api.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      {{- with .Values.podAnnotations }}
+      annotations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      labels:
+        {{- include "hyperfleet-api.selectorLabels" . | nindent 8 }}
+    spec:
+      {{- with .Values.imagePullSecrets }}
+      imagePullSecrets:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      serviceAccountName: {{ include "hyperfleet-api.serviceAccountName" . }}
+      securityContext:
+        {{- toYaml .Values.podSecurityContext | nindent 8 }}
+      {{- if .Values.database.postgresql.enabled }}
+      initContainers:
+      - name: wait-for-db
+        image: busybox:1.36
+        command: ['sh', '-c', 'until nc -z {{ include "hyperfleet-api.fullname" . }}-postgresql {{ .Values.database.postgresql.port }}; do echo waiting for postgresql; sleep 2; done']
+      {{- end }}
+      containers:
+      - name: {{ .Chart.Name }}
+        securityContext:
+          {{- toYaml .Values.securityContext | nindent 10 }}
+        image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        workingDir: /app
+        ports:
+        - name: http
+          containerPort: 8000
+          protocol: TCP
+        env:
+        {{- if .Values.auth.jwksUrl }}
+        - name: JWKS_URL
+          value: {{ .Values.auth.jwksUrl | quote }}
+        {{- end }}
+        - name: ENABLE_JWT
+          value: {{ .Values.auth.enableJwt | quote }}
+        - name: ENABLE_AUTHZ
+          value: {{ .Values.auth.enableAuthz | quote }}
+        {{- if .Values.env }}
+        {{- range .Values.env }}
+        - name: {{ .name }}
+          value: {{ .value | quote }}
+        {{- end }}
+        {{- end }}
+        livenessProbe:
+          httpGet:
+            path: /healthz
+            port: http
+          initialDelaySeconds: 30
+          periodSeconds: 10
+          timeoutSeconds: 5
+          failureThreshold: 3
+        readinessProbe:
+          httpGet:
+            path: /healthz
+            port: http
+          initialDelaySeconds: 5
+          periodSeconds: 5
+          timeoutSeconds: 3
+          failureThreshold: 3
+        resources:
+          {{- toYaml .Values.resources | nindent 10 }}
+        volumeMounts:
+        - name: secrets
+          mountPath: /build/secrets
+          readOnly: true
+        {{- if .Values.extraVolumeMounts }}
+        {{- toYaml .Values.extraVolumeMounts | nindent 8 }}
+        {{- end }}
+      volumes:
+      - name: secrets
+        secret:
+          {{- if .Values.database.external.enabled }}
+          secretName: {{ .Values.database.external.secretName }}
+          {{- else if .Values.database.postgresql.enabled }}
+          secretName: {{ include "hyperfleet-api.fullname" . }}-db-secrets
+          {{- end }}
+      {{- if .Values.extraVolumes }}
+      {{- toYaml .Values.extraVolumes | nindent 6 }}
+      {{- end }}
+      {{- 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/charts/templates/hpa.yaml b/charts/templates/hpa.yaml
new file mode 100644
index 00000000..cb738bb2
--- /dev/null
+++ b/charts/templates/hpa.yaml
@@ -0,0 +1,32 @@
+{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+spec:
+  scaleTargetRef:
+    apiVersion: apps/v1
+    kind: Deployment
+    name: {{ include "hyperfleet-api.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 }}
+    {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
+    - type: Resource
+      resource:
+        name: memory
+        target:
+          type: Utilization
+          averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
+    {{- end }}
+{{- end }}
diff --git a/charts/templates/postgresql.yaml b/charts/templates/postgresql.yaml
new file mode 100644
index 00000000..145a8a64
--- /dev/null
+++ b/charts/templates/postgresql.yaml
@@ -0,0 +1,120 @@
+{{- if .Values.database.postgresql.enabled }}
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}-db-secrets
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+type: Opaque
+stringData:
+  db.host: {{ include "hyperfleet-api.fullname" . }}-postgresql
+  db.port: {{ .Values.database.postgresql.port | quote }}
+  db.name: {{ .Values.database.postgresql.database }}
+  db.user: {{ .Values.database.postgresql.user }}
+  db.password: {{ .Values.database.postgresql.password }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}-postgresql
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+    app.kubernetes.io/component: postgresql
+spec:
+  type: ClusterIP
+  ports:
+  - port: {{ .Values.database.postgresql.port }}
+    targetPort: postgresql
+    protocol: TCP
+    name: postgresql
+  selector:
+    {{- include "hyperfleet-api.selectorLabels" . | nindent 4 }}
+    app.kubernetes.io/component: postgresql
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}-postgresql
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+    app.kubernetes.io/component: postgresql
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      {{- include "hyperfleet-api.selectorLabels" . | nindent 6 }}
+      app.kubernetes.io/component: postgresql
+  template:
+    metadata:
+      labels:
+        {{- include "hyperfleet-api.selectorLabels" . | nindent 8 }}
+        app.kubernetes.io/component: postgresql
+    spec:
+      containers:
+      - name: postgresql
+        image: {{ .Values.database.postgresql.image }}
+        ports:
+        - name: postgresql
+          containerPort: {{ .Values.database.postgresql.port }}
+          protocol: TCP
+        env:
+        - name: POSTGRES_DB
+          value: {{ .Values.database.postgresql.database }}
+        - name: POSTGRES_USER
+          value: {{ .Values.database.postgresql.user }}
+        - name: POSTGRES_PASSWORD
+          value: {{ .Values.database.postgresql.password }}
+        resources:
+          {{- toYaml .Values.database.postgresql.resources | nindent 10 }}
+        {{- if .Values.database.postgresql.persistence.enabled }}
+        volumeMounts:
+        - name: data
+          mountPath: /var/lib/postgresql/data
+        {{- end }}
+        readinessProbe:
+          exec:
+            command:
+            - pg_isready
+            - -U
+            - {{ .Values.database.postgresql.user }}
+            - -d
+            - {{ .Values.database.postgresql.database }}
+          initialDelaySeconds: 5
+          periodSeconds: 5
+        livenessProbe:
+          exec:
+            command:
+            - pg_isready
+            - -U
+            - {{ .Values.database.postgresql.user }}
+            - -d
+            - {{ .Values.database.postgresql.database }}
+          initialDelaySeconds: 30
+          periodSeconds: 10
+      {{- if .Values.database.postgresql.persistence.enabled }}
+      volumes:
+      - name: data
+        persistentVolumeClaim:
+          claimName: {{ include "hyperfleet-api.fullname" . }}-postgresql
+      {{- end }}
+---
+{{- if .Values.database.postgresql.persistence.enabled }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}-postgresql
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+    app.kubernetes.io/component: postgresql
+spec:
+  accessModes:
+  - ReadWriteOnce
+  resources:
+    requests:
+      storage: {{ .Values.database.postgresql.persistence.size }}
+  {{- if .Values.database.postgresql.persistence.storageClass }}
+  storageClassName: {{ .Values.database.postgresql.persistence.storageClass }}
+  {{- end }}
+{{- end }}
+{{- end }}
diff --git a/charts/templates/service.yaml b/charts/templates/service.yaml
new file mode 100644
index 00000000..ca6b65c5
--- /dev/null
+++ b/charts/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "hyperfleet-api.fullname" . }}
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.service.type }}
+  ports:
+  - port: {{ .Values.service.port }}
+    targetPort: http
+    protocol: TCP
+    name: http
+  selector:
+    {{- include "hyperfleet-api.selectorLabels" . | nindent 4 }}
diff --git a/charts/templates/serviceaccount.yaml b/charts/templates/serviceaccount.yaml
new file mode 100644
index 00000000..bead449b
--- /dev/null
+++ b/charts/templates/serviceaccount.yaml
@@ -0,0 +1,12 @@
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ include "hyperfleet-api.serviceAccountName" . }}
+  labels:
+    {{- include "hyperfleet-api.labels" . | nindent 4 }}
+  {{- with .Values.serviceAccount.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+{{- end }}
diff --git a/charts/values.yaml b/charts/values.yaml
new file mode 100644
index 00000000..a227c3d5
--- /dev/null
+++ b/charts/values.yaml
@@ -0,0 +1,118 @@
+# Default values for hyperfleet-api
+# This is a YAML-formatted file.
+
+replicaCount: 1
+
+image:
+  registry: quay.io/openshift-hyperfleet
+  repository: hyperfleet-api
+  pullPolicy: Always
+  tag: "latest"
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+serviceAccount:
+  # Specifies whether a service account should be created
+  create: true
+  # Annotations to add to the service account
+  annotations: {}
+  # The name of the service account to use.
+  # If not set and create is true, a name is generated using the fullname template
+  name: ""
+
+podAnnotations: {}
+
+podSecurityContext:
+  fsGroup: 65532
+  runAsNonRoot: true
+  runAsUser: 65532
+
+securityContext:
+  allowPrivilegeEscalation: false
+  capabilities:
+    drop:
+    - ALL
+  readOnlyRootFilesystem: false
+  seccompProfile:
+    type: RuntimeDefault
+
+service:
+  type: ClusterIP
+  port: 8000
+
+resources:
+  limits:
+    cpu: 500m
+    memory: 512Mi
+  requests:
+    cpu: 100m
+    memory: 128Mi
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
+
+autoscaling:
+  enabled: false
+  minReplicas: 1
+  maxReplicas: 10
+  targetCPUUtilizationPercentage: 80
+  targetMemoryUtilizationPercentage: 80
+
+# Database configuration
+database:
+  # For PRODUCTION: Use external database (GCP Cloud SQL, etc.)
+  # Set external.enabled=true and provide connection details
+  #
+  # For DEVELOPMENT: Use built-in PostgreSQL pod
+  # Set postgresql.enabled=true (default)
+
+  # External database configuration (production)
+  external:
+    enabled: false
+    # Name of existing secret with db.host, db.port, db.name, db.user, db.password keys
+    secretName: ""
+
+  # Built-in PostgreSQL for development/testing
+  postgresql:
+    enabled: true
+    image: docker.io/library/postgres:14.2
+    database: hyperfleet
+    user: hyperfleet
+    password: hyperfleet-dev-password
+    port: 5432
+    resources:
+      limits:
+        cpu: 500m
+        memory: 512Mi
+      requests:
+        cpu: 100m
+        memory: 128Mi
+    persistence:
+      enabled: false
+      size: 1Gi
+      storageClass: ""
+
+# Authentication configuration
+auth:
+  # Enable JWT authentication
+  enableJwt: true
+  # Enable authorization
+  enableAuthz: true
+  # JWKS URL for token verification
+  jwksUrl: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs"
+
+# Additional environment variables
+env: []
+  # - name: GLOG_V
+  #   value: "10"
+
+# Volume mounts for additional configs
+extraVolumeMounts: []
+
+# Additional volumes
+extraVolumes: []

From ba0c890b05e8a0ce7f9af086c01f2b9925b05f95 Mon Sep 17 00:00:00 2001
From: Ciaran Roche <croche@redhat.com>
Date: Tue, 9 Dec 2025 11:55:45 +0000
Subject: [PATCH 2/5] Address PR review feedback for Helm chart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Guard secrets volume against both DB modes being disabled
- Use simple name for Service (hyperfleet-api) instead of fullname
  for predictable service discovery across components
- Add JWKS URL override comment for custom identity providers
- Disable enableAuthz by default (not required for MVP)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 charts/templates/deployment.yaml | 8 +++++++-
 charts/templates/service.yaml    | 2 +-
 charts/values.yaml               | 5 +++--
 3 files changed, 11 insertions(+), 4 deletions(-)

diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml
index 194d0df9..f1cdf143 100644
--- a/charts/templates/deployment.yaml
+++ b/charts/templates/deployment.yaml
@@ -77,24 +77,30 @@ spec:
           failureThreshold: 3
         resources:
           {{- toYaml .Values.resources | nindent 10 }}
+        {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled }}
         volumeMounts:
         - name: secrets
           mountPath: /build/secrets
           readOnly: true
+        {{- end }}
         {{- if .Values.extraVolumeMounts }}
         {{- toYaml .Values.extraVolumeMounts | nindent 8 }}
         {{- end }}
+      {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled .Values.extraVolumes }}
       volumes:
+      {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled }}
       - name: secrets
         secret:
           {{- if .Values.database.external.enabled }}
           secretName: {{ .Values.database.external.secretName }}
-          {{- else if .Values.database.postgresql.enabled }}
+          {{- else }}
           secretName: {{ include "hyperfleet-api.fullname" . }}-db-secrets
           {{- end }}
+      {{- end }}
       {{- if .Values.extraVolumes }}
       {{- toYaml .Values.extraVolumes | nindent 6 }}
       {{- end }}
+      {{- end }}
       {{- with .Values.nodeSelector }}
       nodeSelector:
         {{- toYaml . | nindent 8 }}
diff --git a/charts/templates/service.yaml b/charts/templates/service.yaml
index ca6b65c5..1de09696 100644
--- a/charts/templates/service.yaml
+++ b/charts/templates/service.yaml
@@ -1,7 +1,7 @@
 apiVersion: v1
 kind: Service
 metadata:
-  name: {{ include "hyperfleet-api.fullname" . }}
+  name: {{ include "hyperfleet-api.name" . }}
   labels:
     {{- include "hyperfleet-api.labels" . | nindent 4 }}
 spec:
diff --git a/charts/values.yaml b/charts/values.yaml
index a227c3d5..25b59318 100644
--- a/charts/values.yaml
+++ b/charts/values.yaml
@@ -101,9 +101,10 @@ database:
 auth:
   # Enable JWT authentication
   enableJwt: true
-  # Enable authorization
-  enableAuthz: true
+  # Enable authorization (not required for MVP)
+  enableAuthz: false
   # JWKS URL for token verification
+  # Override with: --set auth.jwksUrl="https://your-idp/.../.well-known/jwks.json"
   jwksUrl: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs"
 
 # Additional environment variables

From 3e39da1d615dfbaf7fa52cf625924135c79db56d Mon Sep 17 00:00:00 2001
From: Ciaran Roche <croche@redhat.com>
Date: Tue, 9 Dec 2025 16:23:47 +0000
Subject: [PATCH 3/5] Fix Helm chart for Kubernetes deployment (HYPERFLEET-312)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Fix bind addresses to use :PORT format for all interfaces
- Add health and metrics ports to deployment
- Fix health probe path from /healthz to /healthcheck
- Add app.kubernetes.io/component label to differentiate from PostgreSQL
- Add .helmignore to exclude .git from chart packaging
- Add configurable server bind addresses in values.yaml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 charts/.helmignore               | 26 ++++++++++++++++++++++++++
 charts/templates/_helpers.tpl    |  1 +
 charts/templates/deployment.yaml | 19 +++++++++++++++----
 charts/values.yaml               |  7 +++++++
 4 files changed, 49 insertions(+), 4 deletions(-)
 create mode 100644 charts/.helmignore

diff --git a/charts/.helmignore b/charts/.helmignore
new file mode 100644
index 00000000..f3586bfd
--- /dev/null
+++ b/charts/.helmignore
@@ -0,0 +1,26 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
+# Parent directory patterns
+../.git/
+../../.git/
diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl
index b1d4c003..af44a49a 100644
--- a/charts/templates/_helpers.tpl
+++ b/charts/templates/_helpers.tpl
@@ -48,6 +48,7 @@ Selector labels
 {{- define "hyperfleet-api.selectorLabels" -}}
 app.kubernetes.io/name: {{ include "hyperfleet-api.name" . }}
 app.kubernetes.io/instance: {{ .Release.Name }}
+app.kubernetes.io/component: api
 {{- end }}
 
 {{/*
diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml
index f1cdf143..67c10bf6 100644
--- a/charts/templates/deployment.yaml
+++ b/charts/templates/deployment.yaml
@@ -40,10 +40,21 @@ spec:
         image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
         imagePullPolicy: {{ .Values.image.pullPolicy }}
         workingDir: /app
+        args:
+        - serve
+        - --api-server-bindaddress={{ .Values.server.bindAddress | default ":8000" }}
+        - --health-check-server-bindaddress={{ .Values.server.healthBindAddress | default ":8083" }}
+        - --metrics-server-bindaddress={{ .Values.server.metricsBindAddress | default ":8080" }}
         ports:
         - name: http
           containerPort: 8000
           protocol: TCP
+        - name: health
+          containerPort: 8083
+          protocol: TCP
+        - name: metrics
+          containerPort: 8080
+          protocol: TCP
         env:
         {{- if .Values.auth.jwksUrl }}
         - name: JWKS_URL
@@ -61,16 +72,16 @@ spec:
         {{- end }}
         livenessProbe:
           httpGet:
-            path: /healthz
-            port: http
+            path: /healthcheck
+            port: health
           initialDelaySeconds: 30
           periodSeconds: 10
           timeoutSeconds: 5
           failureThreshold: 3
         readinessProbe:
           httpGet:
-            path: /healthz
-            port: http
+            path: /healthcheck
+            port: health
           initialDelaySeconds: 5
           periodSeconds: 5
           timeoutSeconds: 3
diff --git a/charts/values.yaml b/charts/values.yaml
index 25b59318..9e179ccf 100644
--- a/charts/values.yaml
+++ b/charts/values.yaml
@@ -13,6 +13,13 @@ imagePullSecrets: []
 nameOverride: ""
 fullnameOverride: ""
 
+# Server bind addresses
+# Use ":PORT" format to bind to all interfaces (required for Kubernetes)
+server:
+  bindAddress: ":8000"
+  healthBindAddress: ":8083"
+  metricsBindAddress: ":8080"
+
 serviceAccount:
   # Specifies whether a service account should be created
   create: true

From 11714b8d351b44b62c03afbd1db91eb83e8b8433 Mon Sep 17 00:00:00 2001
From: Ciaran Roche <croche@redhat.com>
Date: Wed, 10 Dec 2025 11:23:57 +0000
Subject: [PATCH 4/5] Address PR review comments for Helm chart and Dockerfile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add OpenAPI generation stage to Dockerfile (multi-stage build)
- Add health (8083) and metrics (8080) ports to service.yaml
- Add db-migrate init container to run migrations before serve
- Add /tmp emptyDir volume for readOnlyRootFilesystem support
- Enable readOnlyRootFilesystem: true in security context

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 Dockerfile                       | 19 +++++++++++++++++++
 charts/templates/deployment.yaml | 20 ++++++++++++++++----
 charts/templates/service.yaml    |  8 ++++++++
 charts/values.yaml               |  2 +-
 4 files changed, 44 insertions(+), 5 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index a9bcb9a9..6bb7fa0e 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,19 @@
+# OpenAPI generation stage
+FROM openapitools/openapi-generator-cli:v7.16.0 AS openapi-gen
+
+WORKDIR /local
+
+# Copy OpenAPI spec
+COPY openapi/openapi.yaml /local/openapi/openapi.yaml
+
+# Generate Go client/models from OpenAPI spec
+RUN bash /usr/local/bin/docker-entrypoint.sh generate \
+    -i /local/openapi/openapi.yaml \
+    -g go \
+    -o /local/pkg/api/openapi && \
+    rm -f /local/pkg/api/openapi/go.mod /local/pkg/api/openapi/go.sum && \
+    rm -rf /local/pkg/api/openapi/test
+
 # Build stage
 FROM golang:1.24-alpine AS builder
 
@@ -10,6 +26,9 @@ RUN go mod download
 # Copy source code
 COPY . .
 
+# Copy generated OpenAPI code from openapi-gen stage
+COPY --from=openapi-gen /local/pkg/api/openapi ./pkg/api/openapi
+
 # Build binary
 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o hyperfleet-api ./cmd/hyperfleet-api
 
diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml
index 67c10bf6..7ea0b7c5 100644
--- a/charts/templates/deployment.yaml
+++ b/charts/templates/deployment.yaml
@@ -27,12 +27,22 @@ spec:
       serviceAccountName: {{ include "hyperfleet-api.serviceAccountName" . }}
       securityContext:
         {{- toYaml .Values.podSecurityContext | nindent 8 }}
-      {{- if .Values.database.postgresql.enabled }}
+      {{- if or .Values.database.postgresql.enabled .Values.database.external.enabled }}
       initContainers:
+      {{- if .Values.database.postgresql.enabled }}
       - name: wait-for-db
         image: busybox:1.36
         command: ['sh', '-c', 'until nc -z {{ include "hyperfleet-api.fullname" . }}-postgresql {{ .Values.database.postgresql.port }}; do echo waiting for postgresql; sleep 2; done']
       {{- end }}
+      - name: db-migrate
+        image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        command: ["/app/hyperfleet-api", "migrate"]
+        volumeMounts:
+        - name: secrets
+          mountPath: /build/secrets
+          readOnly: true
+      {{- end }}
       containers:
       - name: {{ .Chart.Name }}
         securityContext:
@@ -88,8 +98,10 @@ spec:
           failureThreshold: 3
         resources:
           {{- toYaml .Values.resources | nindent 10 }}
-        {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled }}
         volumeMounts:
+        - name: tmp
+          mountPath: /tmp
+        {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled }}
         - name: secrets
           mountPath: /build/secrets
           readOnly: true
@@ -97,8 +109,9 @@ spec:
         {{- if .Values.extraVolumeMounts }}
         {{- toYaml .Values.extraVolumeMounts | nindent 8 }}
         {{- end }}
-      {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled .Values.extraVolumes }}
       volumes:
+      - name: tmp
+        emptyDir: {}
       {{- if or .Values.database.external.enabled .Values.database.postgresql.enabled }}
       - name: secrets
         secret:
@@ -111,7 +124,6 @@ spec:
       {{- if .Values.extraVolumes }}
       {{- toYaml .Values.extraVolumes | nindent 6 }}
       {{- end }}
-      {{- end }}
       {{- with .Values.nodeSelector }}
       nodeSelector:
         {{- toYaml . | nindent 8 }}
diff --git a/charts/templates/service.yaml b/charts/templates/service.yaml
index 1de09696..6eb36282 100644
--- a/charts/templates/service.yaml
+++ b/charts/templates/service.yaml
@@ -11,5 +11,13 @@ spec:
     targetPort: http
     protocol: TCP
     name: http
+  - port: 8083
+    targetPort: health
+    protocol: TCP
+    name: health
+  - port: 8080
+    targetPort: metrics
+    protocol: TCP
+    name: metrics
   selector:
     {{- include "hyperfleet-api.selectorLabels" . | nindent 4 }}
diff --git a/charts/values.yaml b/charts/values.yaml
index 9e179ccf..d5535f89 100644
--- a/charts/values.yaml
+++ b/charts/values.yaml
@@ -41,7 +41,7 @@ securityContext:
   capabilities:
     drop:
     - ALL
-  readOnlyRootFilesystem: false
+  readOnlyRootFilesystem: true
   seccompProfile:
     type: RuntimeDefault
 

From ca7bfac4109ddd9eebd430095490c4ee03d378f3 Mon Sep 17 00:00:00 2001
From: Ciaran Roche <croche@redhat.com>
Date: Wed, 10 Dec 2025 12:41:06 +0000
Subject: [PATCH 5/5] Add OpenAPI schema to Docker image for validation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Copy openapi/openapi.yaml into the runtime container and set
OPENAPI_SCHEMA_PATH environment variable to enable schema
validation in production deployments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 Dockerfile | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/Dockerfile b/Dockerfile
index 6bb7fa0e..92eae0e0 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -40,6 +40,12 @@ WORKDIR /app
 # Copy binary from builder
 COPY --from=builder /build/hyperfleet-api /app/hyperfleet-api
 
+# Copy OpenAPI schema for validation (uses the source spec, not the generated one)
+COPY --from=builder /build/openapi/openapi.yaml /app/openapi/openapi.yaml
+
+# Set default schema path (can be overridden by Helm for provider-specific schemas)
+ENV OPENAPI_SCHEMA_PATH=/app/openapi/openapi.yaml
+
 EXPOSE 8000
 
 ENTRYPOINT ["/app/hyperfleet-api"]
