diff --git a/.gitignore b/.gitignore index b0054a1..2c617ef 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ dist-ssr *.sln *.sw? +# Worktrees +.worktrees/ + # Database backend/data/ *.db diff --git a/docs/plans/2026-02-23-helm-chart-design.md b/docs/plans/2026-02-23-helm-chart-design.md new file mode 100644 index 0000000..67cd76b --- /dev/null +++ b/docs/plans/2026-02-23-helm-chart-design.md @@ -0,0 +1,491 @@ +# Helm Chart Design + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Helm chart for Kubernetes deployment as an alternative to Docker Compose. + +**Architecture:** Single Helm chart deploying frontend (nginx) and backend (Node.js) services with PVC for SQLite persistence and Ingress for external access. + +**Tech Stack:** Helm 3, Kubernetes + +--- + +## Chart Structure + +``` +helm/cv-app/ +├── Chart.yaml +├── values.yaml +├── templates/ +│ ├── _helpers.tpl +│ ├── frontend-deployment.yaml +│ ├── frontend-service.yaml +│ ├── backend-deployment.yaml +│ ├── backend-service.yaml +│ ├── ingress.yaml +│ ├── configmap.yaml +│ ├── secret.yaml +│ ├── pvc.yaml +│ └── NOTES.txt +└── .helmignore +``` + +## Components + +### Frontend + +- **Deployment**: nginx serving built React static files +- **Service**: ClusterIP on port 80 +- **Environment**: API URL from ConfigMap + +### Backend + +- **Deployment**: Node.js Express server +- **Service**: ClusterIP on port 3001 +- **Volume**: PVC mount at `/app/data` for SQLite +- **Environment**: Port, DB path, auth config from ConfigMap/Secret + +### Ingress + +- Routes `/` → frontend service +- Routes `/api` → backend service +- Configurable TLS +- Supports nginx, traefik ingress controllers + +### Persistence + +- PVC for SQLite database +- Configurable storage class +- 1Gi default size + +## Values Schema + +```yaml +frontend: + replicaCount: 1 + image: + repository: username/cv-app + tag: latest + pullPolicy: IfNotPresent + resources: {} + +backend: + replicaCount: 1 + image: + repository: username/cv-app-backend + tag: latest + pullPolicy: IfNotPresent + auth: + mode: simple + keycloak: + url: "" + realm: "" + clientId: "" + resources: {} + +persistence: + enabled: true + size: 1Gi + storageClass: "" + +ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: cv.local + paths: + - path: / + service: frontend + - path: /api + service: backend + tls: [] + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} +podSecurityContext: {} +securityContext: {} + +nodeSelector: {} +tolerations: [] +affinity: {} +``` + +## Templates + +### _helpers.tpl + +```yaml +{{- define "cv-app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.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 }} + +{{- define "cv-app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.labels" -}} +helm.sh/chart: {{ include "cv-app.chart" . }} +{{ include "cv-app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "cv-app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cv-app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "cv-app.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} +``` + +### frontend-deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cv-app.fullname" . }}-config + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} +``` + +### frontend-service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend +``` + +### backend-deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: 3001 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cv-app.fullname" . }}-config + - secretRef: + name: {{ include "cv-app.fullname" . }}-secret + volumeMounts: + - name: data + mountPath: /app/data + livenessProbe: + httpGet: + path: /health + port: http + readinessProbe: + httpGet: + path: /health + port: http + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "cv-app.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- end }} +``` + +### backend-service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - port: 3001 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend +``` + +### ingress.yaml + +```yaml +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cv-app.fullname" . }} + labels: + {{- include "cv-app.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: Prefix + backend: + service: + name: {{ include "cv-app.fullname" $ }}-{{ .service }} + port: + number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }} + {{- end }} + {{- end }} +{{- end }} +``` + +### configmap.yaml + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cv-app.fullname" . }}-config + labels: + {{- include "cv-app.labels" . | nindent 4 }} +data: + PORT: "3001" + DB_PATH: "/app/data/cv.db" + AUTH_MODE: {{ .Values.backend.auth.mode | quote }} + {{- if eq .Values.backend.auth.mode "keycloak" }} + KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }} + KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }} + {{- end }} +``` + +### secret.yaml + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cv-app.fullname" . }}-secret + labels: + {{- include "cv-app.labels" . | nindent 4 }} +type: Opaque +data: + {{- if eq .Values.backend.auth.mode "simple" }} + JWT_SECRET: {{ randAlphaNum 32 | b64enc | quote }} + {{- end }} +``` + +### pvc.yaml + +```yaml +{{- if .Values.persistence.enabled -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cv-app.fullname" . }}-data + labels: + {{- include "cv-app.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} +``` + +### NOTES.txt + +``` +Your CV application has been deployed! + +Access your application at: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }} +{{- end }} +{{- else }} + Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local + Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001 +{{- end }} + +{{- if eq .Values.backend.auth.mode "simple" }} +Get the admin password: + kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD" +{{- end }} + +API Documentation: + http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/api/docs +``` + +## Usage Examples + +### Basic Installation + +```bash +helm install cv-app ./helm/cv-app +``` + +### With Custom Host + +```bash +helm install cv-app ./helm/cv-app \ + --set ingress.hosts[0].host=cv.example.com +``` + +### With TLS + +```bash +helm install cv-app ./helm/cv-app \ + --set ingress.hosts[0].host=cv.example.com \ + --set ingress.tls[0].hosts[0]=cv.example.com \ + --set ingress.tls[0].secretName=cv-app-tls +``` + +### With Keycloak + +```bash +helm install cv-app ./helm/cv-app \ + --set backend.auth.mode=keycloak \ + --set backend.keycloak.url=https://keycloak.example.com \ + --set backend.keycloak.realm=myrealm \ + --set backend.keycloak.clientId=cv-app +``` + +### With Specific Storage Class + +```bash +helm install cv-app ./helm/cv-app \ + --set persistence.storageClass=local-path \ + --set persistence.size=5Gi +``` diff --git a/docs/plans/2026-02-23-helm-chart-implementation.md b/docs/plans/2026-02-23-helm-chart-implementation.md new file mode 100644 index 0000000..2759418 --- /dev/null +++ b/docs/plans/2026-02-23-helm-chart-implementation.md @@ -0,0 +1,636 @@ +# Helm Chart Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Helm chart for Kubernetes deployment with frontend, backend, ingress, and persistence. + +**Architecture:** Single Helm chart with templates for Deployments, Services, Ingress, ConfigMap, Secret, and PVC. + +**Tech Stack:** Helm 3, Kubernetes + +--- + +### Task 1: Create Helm chart structure + +**Files:** +- Create: `helm/cv-app/Chart.yaml` +- Create: `helm/cv-app/.helmignore` + +**Step 1: Create helm directory** + +```bash +mkdir -p helm/cv-app/templates +``` + +**Step 2: Create Chart.yaml** + +```yaml +apiVersion: v2 +name: cv-app +description: A Helm chart for CV application +type: application +version: 0.1.0 +appVersion: "1.0.0" +maintainers: + - name: Tuan-Dat Tran + email: tuan-dat.tran@tudattr.dev +``` + +**Step 3: Create .helmignore** + +``` +# Patterns to ignore when building packages. +.git/ +.github/ +.vscode/ +.idea/ + +# Test files +*_test.go +tests/ + +# Documentation +*.md +!README.md +docs/ + +# CI/CD +.github/ +``` + +**Step 4: Commit** + +```bash +git add helm/cv-app/Chart.yaml helm/cv-app/.helmignore +git commit -m "feat(helm): add chart structure and metadata" +``` + +--- + +### Task 2: Create helper templates + +**Files:** +- Create: `helm/cv-app/templates/_helpers.tpl` + +**Step 1: Create _helpers.tpl** + +```yaml +{{- define "cv-app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.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 }} + +{{- define "cv-app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.labels" -}} +helm.sh/chart: {{ include "cv-app.chart" . }} +{{ include "cv-app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "cv-app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cv-app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "cv-app.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/templates/_helpers.tpl +git commit -m "feat(helm): add template helpers" +``` + +--- + +### Task 3: Create default values + +**Files:** +- Create: `helm/cv-app/values.yaml` + +**Step 1: Create values.yaml** + +```yaml +frontend: + replicaCount: 1 + image: + repository: username/cv-app + tag: latest + pullPolicy: IfNotPresent + resources: {} + +backend: + replicaCount: 1 + image: + repository: username/cv-app-backend + tag: latest + pullPolicy: IfNotPresent + auth: + mode: simple + keycloak: + url: "" + realm: "" + clientId: "" + resources: {} + +persistence: + enabled: true + size: 1Gi + storageClass: "" + +ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: cv.local + paths: + - path: / + service: frontend + - path: /api + service: backend + tls: [] + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} +podSecurityContext: {} +securityContext: {} + +nodeSelector: {} +tolerations: [] +affinity: {} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/values.yaml +git commit -m "feat(helm): add default values configuration" +``` + +--- + +### Task 4: Create ConfigMap template + +**Files:** +- Create: `helm/cv-app/templates/configmap.yaml` + +**Step 1: Create configmap.yaml** + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cv-app.fullname" . }}-config + labels: + {{- include "cv-app.labels" . | nindent 4 }} +data: + PORT: "3001" + DB_PATH: "/app/data/cv.db" + AUTH_MODE: {{ .Values.backend.auth.mode | quote }} + {{- if eq .Values.backend.auth.mode "keycloak" }} + KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }} + KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }} + {{- end }} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/templates/configmap.yaml +git commit -m "feat(helm): add ConfigMap template" +``` + +--- + +### Task 5: Create Secret template + +**Files:** +- Create: `helm/cv-app/templates/secret.yaml` + +**Step 1: Create secret.yaml** + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cv-app.fullname" . }}-secret + labels: + {{- include "cv-app.labels" . | nindent 4 }} +type: Opaque +data: + {{- if eq .Values.backend.auth.mode "simple" }} + JWT_SECRET: {{ randAlphaNum 32 | b64enc | quote }} + {{- end }} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/templates/secret.yaml +git commit -m "feat(helm): add Secret template for JWT" +``` + +--- + +### Task 6: Create PVC template + +**Files:** +- Create: `helm/cv-app/templates/pvc.yaml` + +**Step 1: Create pvc.yaml** + +```yaml +{{- if .Values.persistence.enabled -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cv-app.fullname" . }}-data + labels: + {{- include "cv-app.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/templates/pvc.yaml +git commit -m "feat(helm): add PersistentVolumeClaim template" +``` + +--- + +### Task 7: Create backend Deployment and Service + +**Files:** +- Create: `helm/cv-app/templates/backend-deployment.yaml` +- Create: `helm/cv-app/templates/backend-service.yaml` + +**Step 1: Create backend-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cv-app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: backend + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: 3001 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cv-app.fullname" . }}-config + - secretRef: + name: {{ include "cv-app.fullname" . }}-secret + volumeMounts: + - name: data + mountPath: /app/data + livenessProbe: + httpGet: + path: /health + port: http + readinessProbe: + httpGet: + path: /health + port: http + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "cv-app.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- 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 }} +``` + +**Step 2: Create backend-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - port: 3001 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend +``` + +**Step 3: Commit** + +```bash +git add helm/cv-app/templates/backend-deployment.yaml helm/cv-app/templates/backend-service.yaml +git commit -m "feat(helm): add backend Deployment and Service templates" +``` + +--- + +### Task 8: Create frontend Deployment and Service + +**Files:** +- Create: `helm/cv-app/templates/frontend-deployment.yaml` +- Create: `helm/cv-app/templates/frontend-service.yaml` + +**Step 1: Create frontend-deployment.yaml** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cv-app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: frontend + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.frontend.resources | 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 }} +``` + +**Step 2: Create frontend-service.yaml** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend +``` + +**Step 3: Commit** + +```bash +git add helm/cv-app/templates/frontend-deployment.yaml helm/cv-app/templates/frontend-service.yaml +git commit -m "feat(helm): add frontend Deployment and Service templates" +``` + +--- + +### Task 9: Create Ingress template + +**Files:** +- Create: `helm/cv-app/templates/ingress.yaml` + +**Step 1: Create ingress.yaml** + +```yaml +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cv-app.fullname" . }} + labels: + {{- include "cv-app.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: Prefix + backend: + service: + name: {{ include "cv-app.fullname" $ }}-{{ .service }} + port: + number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }} + {{- end }} + {{- end }} +{{- end }} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/templates/ingress.yaml +git commit -m "feat(helm): add Ingress template" +``` + +--- + +### Task 10: Create NOTES.txt and update documentation + +**Files:** +- Create: `helm/cv-app/templates/NOTES.txt` +- Modify: `docs/architecture.md` + +**Step 1: Create NOTES.txt** + +``` +Your CV application has been deployed! + +Access your application at: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }} +{{- end }} +{{- else }} + Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local + Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001 +{{- end }} + +{{- if eq .Values.backend.auth.mode "simple" }} +Get the admin password: + kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD" +{{- end }} + +API Documentation: +{{- if .Values.ingress.enabled }} +{{- if .Values.ingress.tls }} + https://{{ (index .Values.ingress.hosts 0).host }}/api/docs +{{- else }} + http://{{ (index .Values.ingress.hosts 0).host }}/api/docs +{{- end }} +{{- else }} + http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001/api/docs +{{- end }} +``` + +**Step 2: Commit** + +```bash +git add helm/cv-app/templates/NOTES.txt +git commit -m "feat(helm): add post-install notes" +``` diff --git a/helm/cv-app/.helmignore b/helm/cv-app/.helmignore new file mode 100644 index 0000000..d5f130b --- /dev/null +++ b/helm/cv-app/.helmignore @@ -0,0 +1,17 @@ +# Patterns to ignore when building packages. +.git/ +.github/ +.vscode/ +.idea/ + +# Test files +*_test.go +tests/ + +# Documentation +*.md +!README.md +docs/ + +# CI/CD +.github/ diff --git a/helm/cv-app/Chart.yaml b/helm/cv-app/Chart.yaml new file mode 100644 index 0000000..efe1386 --- /dev/null +++ b/helm/cv-app/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: cv-app +description: A Helm chart for CV application +type: application +version: 0.1.0 +appVersion: "1.0.0" +maintainers: + - name: Tuan-Dat Tran + email: tuan-dat.tran@tudattr.dev diff --git a/helm/cv-app/templates/NOTES.txt b/helm/cv-app/templates/NOTES.txt new file mode 100644 index 0000000..96fc479 --- /dev/null +++ b/helm/cv-app/templates/NOTES.txt @@ -0,0 +1,27 @@ +Your CV application has been deployed! + +Access your application at: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }} +{{- end }} +{{- else }} + Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local + Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001 +{{- end }} + +{{- if eq .Values.backend.auth.mode "simple" }} +Get the admin password: + kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD" +{{- end }} + +API Documentation: +{{- if .Values.ingress.enabled }} +{{- if .Values.ingress.tls }} + https://{{ (index .Values.ingress.hosts 0).host }}/api/docs +{{- else }} + http://{{ (index .Values.ingress.hosts 0).host }}/api/docs +{{- end }} +{{- else }} + http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001/api/docs +{{- end }} diff --git a/helm/cv-app/templates/_helpers.tpl b/helm/cv-app/templates/_helpers.tpl new file mode 100644 index 0000000..623cb6f --- /dev/null +++ b/helm/cv-app/templates/_helpers.tpl @@ -0,0 +1,42 @@ +{{- define "cv-app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.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 }} + +{{- define "cv-app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.labels" -}} +helm.sh/chart: {{ include "cv-app.chart" . }} +{{ include "cv-app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "cv-app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cv-app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "cv-app.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/helm/cv-app/templates/backend-deployment.yaml b/helm/cv-app/templates/backend-deployment.yaml new file mode 100644 index 0000000..c9709dd --- /dev/null +++ b/helm/cv-app/templates/backend-deployment.yaml @@ -0,0 +1,80 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cv-app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: backend + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: 3001 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cv-app.fullname" . }}-config + - secretRef: + name: {{ include "cv-app.fullname" . }}-secret + volumeMounts: + - name: data + mountPath: /app/data + livenessProbe: + httpGet: + path: /health + port: http + readinessProbe: + httpGet: + path: /health + port: http + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "cv-app.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- 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/helm/cv-app/templates/backend-service.yaml b/helm/cv-app/templates/backend-service.yaml new file mode 100644 index 0000000..1f6ff93 --- /dev/null +++ b/helm/cv-app/templates/backend-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - port: 3001 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend diff --git a/helm/cv-app/templates/configmap.yaml b/helm/cv-app/templates/configmap.yaml new file mode 100644 index 0000000..87bd0ff --- /dev/null +++ b/helm/cv-app/templates/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cv-app.fullname" . }}-config + labels: + {{- include "cv-app.labels" . | nindent 4 }} +data: + PORT: "3001" + DB_PATH: "/app/data/cv.db" + AUTH_MODE: {{ .Values.backend.auth.mode | quote }} + {{- if eq .Values.backend.auth.mode "keycloak" }} + KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }} + KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }} + {{- end }} diff --git a/helm/cv-app/templates/frontend-deployment.yaml b/helm/cv-app/templates/frontend-deployment.yaml new file mode 100644 index 0000000..a9ffc48 --- /dev/null +++ b/helm/cv-app/templates/frontend-deployment.yaml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cv-app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: frontend + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.frontend.resources | 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/cv-app/templates/frontend-service.yaml b/helm/cv-app/templates/frontend-service.yaml new file mode 100644 index 0000000..0e36054 --- /dev/null +++ b/helm/cv-app/templates/frontend-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend diff --git a/helm/cv-app/templates/ingress.yaml b/helm/cv-app/templates/ingress.yaml new file mode 100644 index 0000000..58ae8ff --- /dev/null +++ b/helm/cv-app/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cv-app.fullname" . }} + labels: + {{- include "cv-app.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: Prefix + backend: + service: + name: {{ include "cv-app.fullname" $ }}-{{ .service }} + port: + number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/cv-app/templates/pvc.yaml b/helm/cv-app/templates/pvc.yaml new file mode 100644 index 0000000..27e2fc6 --- /dev/null +++ b/helm/cv-app/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cv-app.fullname" . }}-data + labels: + {{- include "cv-app.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/helm/cv-app/templates/secret.yaml b/helm/cv-app/templates/secret.yaml new file mode 100644 index 0000000..705ed1b --- /dev/null +++ b/helm/cv-app/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if eq .Values.backend.auth.mode "simple" }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cv-app.fullname" . }}-secret + labels: + {{- include "cv-app.labels" . | nindent 4 }} +type: Opaque +data: + JWT_SECRET: {{ .Values.backend.auth.jwtSecret | default (randAlphaNum 32) | b64enc | quote }} +{{- end }} diff --git a/helm/cv-app/values.yaml b/helm/cv-app/values.yaml new file mode 100644 index 0000000..dd8d0cc --- /dev/null +++ b/helm/cv-app/values.yaml @@ -0,0 +1,57 @@ +frontend: + replicaCount: 1 + image: + repository: username/cv-app + tag: latest + pullPolicy: IfNotPresent + resources: {} + +backend: + replicaCount: 1 + image: + repository: username/cv-app-backend + tag: latest + pullPolicy: IfNotPresent + auth: + mode: simple + jwtSecret: "" + keycloak: + url: "" + realm: "" + clientId: "" + resources: {} + +persistence: + enabled: true + size: 1Gi + storageClass: "" + +ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: cv.local + paths: + - path: /api + service: backend + - path: / + service: frontend + tls: [] + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} +podSecurityContext: {} +securityContext: {} + +nodeSelector: {} +tolerations: [] +affinity: {}