diff --git a/.github/workflows/helm-test.yml b/.github/workflows/helm-test.yml new file mode 100644 index 0000000..1255ce1 --- /dev/null +++ b/.github/workflows/helm-test.yml @@ -0,0 +1,74 @@ +name: Helm Chart Test + +on: + push: + branches: [main] + paths: + - "helm/**" + - ".github/workflows/helm-test.yml" + pull_request: + branches: [main] + paths: + - "helm/**" + - ".github/workflows/helm-test.yml" + +permissions: + contents: read + +jobs: + test-helm-chart: + name: Test Helm Chart + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Build Docker image + uses: docker/build-push-action@c382f710d39a5bb4e430307530a720f50c2d3318 # v6.0.0 + with: + context: . + load: true + tags: toolhive-cloud-ui:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create Kind cluster + uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0 + + - name: Load image into Kind + run: | + kind load docker-image toolhive-cloud-ui:latest --name chart-testing + + - name: Install Helm chart + run: | + helm upgrade --install toolhive-cloud-ui ./helm \ + -f ./helm/values-dev.yaml \ + --wait \ + --timeout=5m + + - name: Check deployment status + run: | + kubectl get pods -l app.kubernetes.io/name=toolhive-cloud-ui + kubectl get svc -l app.kubernetes.io/name=toolhive-cloud-ui + + - name: Verify application is responding + run: | + kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=toolhive-cloud-ui --timeout=120s + kubectl port-forward svc/toolhive-cloud-ui 8080:80 & + sleep 5 + curl -f http://localhost:8080 || (kubectl logs -l app.kubernetes.io/name=toolhive-cloud-ui && exit 1) + + - name: Run Helm tests (if any) + run: | + helm test toolhive-cloud-ui || echo "No tests defined" + + - name: Show logs on failure + if: failure() + run: | + kubectl get all -l app.kubernetes.io/name=toolhive-cloud-ui + kubectl describe pods -l app.kubernetes.io/name=toolhive-cloud-ui + kubectl logs -l app.kubernetes.io/name=toolhive-cloud-ui --tail=100 diff --git a/.github/workflows/lint-helm.yml b/.github/workflows/lint-helm.yml new file mode 100644 index 0000000..740fed7 --- /dev/null +++ b/.github/workflows/lint-helm.yml @@ -0,0 +1,42 @@ +name: Lint Helm Chart + +on: + push: + branches: [main] + paths: + - "helm/**" + - ".github/workflows/lint-helm.yml" + pull_request: + branches: [main] + paths: + - "helm/**" + - ".github/workflows/lint-helm.yml" + +permissions: + contents: read + +jobs: + lint-chart: + name: Lint Helm Chart + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0 + with: + version: "latest" + + - name: Run Helm lint + run: | + helm lint ./helm + helm lint ./helm -f ./helm/values-dev.yaml + + - name: Validate templates + run: | + helm template toolhive-cloud-ui ./helm --kube-version 1.28.0 --debug + helm template toolhive-cloud-ui ./helm -f ./helm/values-dev.yaml --kube-version 1.28.0 --debug diff --git a/.gitignore b/.gitignore index 5ef6a52..5eb4b53 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# helm +*.tgz +helm/charts/ +helm/*.lock diff --git a/Dockerfile b/Dockerfile index 1009418..8e38501 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN corepack enable && corepack prepare pnpm@10.18.3 --activate WORKDIR /app -# Install dependencies based on the preferred package manager +# Install dependencies COPY package.json pnpm-lock.yaml* ./ RUN pnpm install --frozen-lockfile diff --git a/Makefile b/Makefile index 4033492..c577545 100644 --- a/Makefile +++ b/Makefile @@ -5,11 +5,31 @@ IMAGE_NAME := toolhive-cloud-ui IMAGE_TAG := latest CONTAINER_NAME := toolhive-cloud-ui PORT := 3000 +RELEASE_NAME := toolhive-cloud-ui ## Show this help message help: - @echo "Available commands:" - @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## //' | awk 'NR%2==1{printf "\033[36m%-15s\033[0m ",$$1} NR%2==0{print}' + @echo "ToolHive Cloud UI - Available Commands" + @echo "" + @echo "Docker (Local Development):" + @echo " make build - Build production Docker image" + @echo " make start - Start Docker container" + @echo " make stop - Stop Docker container" + @echo " make logs - View container logs" + @echo " make clean - Remove container and image" + @echo " make rebuild - Clean and rebuild" + @echo "" + @echo "Kind (Kubernetes):" + @echo " make kind-setup - Create cluster and deploy (first time)" + @echo " make kind-create - Create Kind cluster" + @echo " make kind-deploy - Build and deploy to Kind" + @echo " make kind-port-forward - Port-forward to localhost:8080" + @echo " make kind-logs - View application logs" + @echo " make kind-uninstall - Uninstall from Kind" + @echo " make kind-delete - Delete Kind cluster" + @echo "" + @echo "Development:" + @echo " make dev - Run Next.js dev server" ## Build the production docker image build: @@ -51,3 +71,52 @@ shell: rebuild: clean build @echo "Rebuild complete" +## Create Kind cluster +kind-create: + @echo "Creating Kind cluster..." + @kind create cluster --name toolhive || echo "Cluster already exists" + @kubectl cluster-info --context kind-toolhive + @echo "Kind cluster ready!" + +## Delete Kind cluster +kind-delete: + @echo "Deleting Kind cluster..." + @kind delete cluster --name toolhive + @echo "Cluster deleted" + +## Build and load image into Kind +kind-build: + @echo "Building Docker image..." + @docker build -t $(IMAGE_NAME):$(IMAGE_TAG) . + @echo "Loading image into Kind cluster..." + @kind load docker-image $(IMAGE_NAME):$(IMAGE_TAG) --name toolhive + @echo "Image loaded successfully" + +## Deploy to Kind with Helm +kind-deploy: kind-build + @echo "Deploying to Kind..." + @helm upgrade --install $(RELEASE_NAME) ./helm -f ./helm/values-dev.yaml --wait --timeout=5m + @echo "Deployment complete!" + @echo "" + @echo "To access the application, run:" + @echo " make kind-port-forward" + @echo "Then open: http://localhost:8080" + +## Uninstall from Kind +kind-uninstall: + @helm uninstall $(RELEASE_NAME) || true + @echo "Uninstalled from Kind" + +## View logs +kind-logs: + @kubectl logs -f deployment/$(RELEASE_NAME) + +## Port-forward to localhost +kind-port-forward: + @echo "Forwarding to http://localhost:8080" + @kubectl port-forward svc/$(RELEASE_NAME) 8080:80 + +## Full setup: create cluster and deploy +kind-setup: kind-create kind-deploy + @echo "Setup complete!" + diff --git a/README.md b/README.md index cd01ecc..aa73967 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,59 @@ make clean # Rebuild from scratch make rebuild +``` The application will be available at [http://localhost:3000](http://localhost:3000). +## Kubernetes / Kind Deployment + +This project includes a complete Helm chart for deploying to Kubernetes (optimized for Kind). + +### Quick Start with Kind + +```bash +# Create cluster and deploy (first time) +make kind-setup + +# Or step by step: +# 1. Create Kind cluster +make kind-create + +# 2. Deploy application +make kind-deploy + +# 3. Access the application +make kind-port-forward +# Then open: http://localhost:8080 + +# View logs +make kind-logs + +# Uninstall +make kind-uninstall + +# Delete cluster +make kind-delete +``` + +### Helm Chart + +The Helm chart is located in the `helm/` directory and includes: + +- Deployment with configurable replicas +- Service (ClusterIP/NodePort/LoadBalancer) +- Horizontal Pod Autoscaler (optional) +- Configurable resource limits +- Health checks (startup, liveness and readiness probes) +- Security contexts following Pod Security Standards + +### CI/CD + +The chart is automatically tested on every push using GitHub Actions with Kind: + +- **Helm Lint**: Validates chart syntax and best practices +- **Integration Test**: Deploys to Kind cluster and verifies the app responds + ## Learn More To learn more about Next.js, take a look at the following resources: diff --git a/helm/.helmignore b/helm/.helmignore new file mode 100644 index 0000000..898df48 --- /dev/null +++ b/helm/.helmignore @@ -0,0 +1,24 @@ +# 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/ + diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..88e9cf3 --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,15 @@ +apiVersion: v2 +name: toolhive-cloud-ui +description: A Helm chart for ToolHive Cloud UI - Next.js application +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - nextjs + - react + - frontend + - ui +home: https://github.com/stacklok/toolhive-cloud-ui +sources: + - https://github.com/stacklok/toolhive-cloud-ui +kubeVersion: ">=1.24.0-0" diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl new file mode 100644 index 0000000..4e3c138 --- /dev/null +++ b/helm/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "toolhive-cloud-ui.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "toolhive-cloud-ui.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 "toolhive-cloud-ui.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "toolhive-cloud-ui.labels" -}} +helm.sh/chart: {{ include "toolhive-cloud-ui.chart" . }} +{{ include "toolhive-cloud-ui.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/component: frontend +app.kubernetes.io/part-of: toolhive +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "toolhive-cloud-ui.selectorLabels" -}} +app.kubernetes.io/name: {{ include "toolhive-cloud-ui.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "toolhive-cloud-ui.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "toolhive-cloud-ui.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml new file mode 100644 index 0000000..3e4bdda --- /dev/null +++ b/helm/templates/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "toolhive-cloud-ui.fullname" . }} + labels: + {{- include "toolhive-cloud-ui.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "toolhive-cloud-ui.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "toolhive-cloud-ui.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "toolhive-cloud-ui.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + {{- if .Values.startupProbe }} + startupProbe: + {{- toYaml .Values.startupProbe | nindent 12 }} + {{- end }} + {{- if .Values.livenessProbe }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + {{- end }} + {{- if .Values.readinessProbe }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.envFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- 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/templates/hpa.yaml b/helm/templates/hpa.yaml new file mode 100644 index 0000000..ac62c21 --- /dev/null +++ b/helm/templates/hpa.yaml @@ -0,0 +1,33 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "toolhive-cloud-ui.fullname" . }} + labels: + {{- include "toolhive-cloud-ui.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "toolhive-cloud-ui.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/helm/templates/service.yaml b/helm/templates/service.yaml new file mode 100644 index 0000000..fa938c1 --- /dev/null +++ b/helm/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "toolhive-cloud-ui.fullname" . }} + labels: + {{- include "toolhive-cloud-ui.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + {{- include "toolhive-cloud-ui.selectorLabels" . | nindent 4 }} + diff --git a/helm/templates/serviceaccount.yaml b/helm/templates/serviceaccount.yaml new file mode 100644 index 0000000..eb9078c --- /dev/null +++ b/helm/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "toolhive-cloud-ui.serviceAccountName" . }} + labels: + {{- include "toolhive-cloud-ui.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} + diff --git a/helm/values-dev.yaml b/helm/values-dev.yaml new file mode 100644 index 0000000..b712868 --- /dev/null +++ b/helm/values-dev.yaml @@ -0,0 +1,66 @@ +# Development values for local Kubernetes Kind +# Usage: helm install toolhive-cloud-ui ./helm -f ./helm/values-dev.yaml + +# -- Number of replicas for development +replicaCount: 1 + +image: + # -- Image repository + repository: toolhive-cloud-ui + # -- Pull from registry if available, use local image if not + pullPolicy: IfNotPresent + # -- Override with "latest" for local development + tag: "latest" + +service: + type: ClusterIP + port: 80 + targetPort: 3000 + +# Reduced resources for development environment +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +# Faster and more permissive probes for development +startupProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 0 + periodSeconds: 3 + timeoutSeconds: 2 + successThreshold: 1 + failureThreshold: 20 + +livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 5 + +readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 0 + periodSeconds: 5 + timeoutSeconds: 2 + successThreshold: 1 + failureThreshold: 3 + +# Environment variables for development (example) +env: [] +# - name: NEXT_PUBLIC_API_URL +# value: "http://api.toolhive-ui.local" +# - name: NODE_ENV +# value: "development" + diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..ee28519 --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,153 @@ +# -- Number of replicas for the deployment +replicaCount: 1 + +image: + # -- Image repository + repository: toolhive-cloud-ui + # -- Pull from registry if available, use local image if not + pullPolicy: IfNotPresent + # -- Overrides the image tag whose default is the chart appVersion + tag: "" + +# -- Secrets for image pull (if using private registry) +imagePullSecrets: [] + +# -- Override the name of the chart +nameOverride: "" + +# -- Override the full name of the resources +fullnameOverride: "" + +serviceAccount: + # -- Specifies whether a service account should be created + create: false + # -- Automatically mount a ServiceAccount's API credentials + automount: 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: "" + +# -- Annotations to add to the pod +podAnnotations: {} + +# -- Labels to add to the pod +podLabels: {} + +# -- Security context for the pod +podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + +# -- Security context for the container +securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + +service: + # -- Kubernetes service type + type: ClusterIP + # -- Service port + port: 80 + # -- Container target port + targetPort: 3000 + # -- NodePort (only used if type is NodePort, range: 30000-32767) + nodePort: null + +# -- Resource limits and requests for the container +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +# -- Startup probe configuration (checks if app is ready to start) +startupProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 0 + periodSeconds: 5 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 30 + +# -- Liveness probe configuration (checks if app is alive) +livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + +# -- Readiness probe configuration (checks if app can receive traffic) +readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 0 + periodSeconds: 5 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + +autoscaling: + # -- Enable Horizontal Pod Autoscaler + enabled: false + # -- Minimum number of replicas + minReplicas: 1 + # -- Maximum number of replicas + maxReplicas: 5 + # -- Target CPU utilization percentage + targetCPUUtilizationPercentage: 80 + # -- Target memory utilization percentage + # targetMemoryUtilizationPercentage: 80 + +# -- Additional volumes for the pod +volumes: [] +# - name: config +# configMap: +# name: app-config + +# -- Additional volume mounts for the container +volumeMounts: [] +# - name: config +# mountPath: /etc/config +# readOnly: true + +# -- Node labels for pod assignment +nodeSelector: {} + +# -- Tolerations for pod assignment +tolerations: [] + +# -- Affinity rules for pod assignment +affinity: {} + +# -- Environment variables for the container +env: [] +# - name: NEXT_PUBLIC_API_URL +# value: "https://api.example.com" +# - name: NODE_ENV +# value: "production" + +# -- Environment variables from ConfigMaps or Secrets +envFrom: [] +# - configMapRef: +# name: app-config +# - secretRef: +# name: app-secrets