diff --git a/deploy-k3s/manifests/ingress/ingress-simple.yaml b/deploy-k3s/manifests/ingress/ingress-simple.yaml index bdfbe88..f9c6c6f 100644 --- a/deploy-k3s/manifests/ingress/ingress-simple.yaml +++ b/deploy-k3s/manifests/ingress/ingress-simple.yaml @@ -59,3 +59,24 @@ spec: name: admin port: number: 3000 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: honeydue-web + namespace: honeydue + labels: + app.kubernetes.io/part-of: honeydue +spec: + ingressClassName: traefik + rules: + - host: app.myhoneydue.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: web + port: + number: 3000 diff --git a/deploy-k3s/manifests/pod-disruption-budgets.yaml b/deploy-k3s/manifests/pod-disruption-budgets.yaml index f4b60e4..6c43da5 100644 --- a/deploy-k3s/manifests/pod-disruption-budgets.yaml +++ b/deploy-k3s/manifests/pod-disruption-budgets.yaml @@ -1,5 +1,6 @@ # Pod Disruption Budgets — prevent node maintenance from killing all replicas # API: at least 2 of 3 replicas must stay up during voluntary disruptions +# Web: same (3 replicas, at least 2 up) — production web client availability # Worker: singleton (Asynq scheduler) — must allow drain, minAvailable: 0 apiVersion: policy/v1 @@ -16,6 +17,21 @@ spec: matchLabels: app.kubernetes.io/name: api +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: web-pdb + namespace: honeydue + labels: + app.kubernetes.io/name: web + app.kubernetes.io/part-of: honeydue +spec: + minAvailable: 2 + selector: + matchLabels: + app.kubernetes.io/name: web + --- apiVersion: policy/v1 kind: PodDisruptionBudget diff --git a/deploy-k3s/manifests/rbac.yaml b/deploy-k3s/manifests/rbac.yaml index 69e0860..891e840 100644 --- a/deploy-k3s/manifests/rbac.yaml +++ b/deploy-k3s/manifests/rbac.yaml @@ -34,6 +34,17 @@ metadata: app.kubernetes.io/part-of: honeydue automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: web + namespace: honeydue + labels: + app.kubernetes.io/name: web + app.kubernetes.io/part-of: honeydue +automountServiceAccountToken: false + --- apiVersion: v1 kind: ServiceAccount diff --git a/deploy-k3s/manifests/web/deployment.yaml b/deploy-k3s/manifests/web/deployment.yaml new file mode 100644 index 0000000..4876ccf --- /dev/null +++ b/deploy-k3s/manifests/web/deployment.yaml @@ -0,0 +1,107 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: honeydue + labels: + app.kubernetes.io/name: web + app.kubernetes.io/part-of: honeydue +spec: + replicas: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: web + template: + metadata: + labels: + app.kubernetes.io/name: web + app.kubernetes.io/part-of: honeydue + spec: + serviceAccountName: web + imagePullSecrets: + - name: ghcr-credentials + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + # Spread pods across nodes so one node loss drops at most one replica. + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: web + containers: + - name: web + image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed + ports: + - containerPort: 3000 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + env: + - name: PORT + value: "3000" + - name: HOSTNAME + value: "0.0.0.0" + # Server-side proxy target (src/app/api/proxy/[...path]/route.ts, + # src/app/api/auth/*/route.ts). Browser never sees this URL. + - name: API_URL + valueFrom: + configMapKeyRef: + name: honeydue-config + key: API_URL + volumeMounts: + # Next.js writes its build cache here; readOnlyRootFilesystem blocks + # writing to /app/.next/cache otherwise. + - name: nextjs-cache + mountPath: /app/.next/cache + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + # Next.js standalone serves at `/`. `/login` also 200s pre-auth. + startupProbe: + httpGet: + path: / + port: 3000 + failureThreshold: 24 + periodSeconds: 5 + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + volumes: + - name: nextjs-cache + emptyDir: + sizeLimit: 256Mi + - name: tmp + emptyDir: + sizeLimit: 64Mi diff --git a/deploy-k3s/manifests/web/service.yaml b/deploy-k3s/manifests/web/service.yaml new file mode 100644 index 0000000..11f4e58 --- /dev/null +++ b/deploy-k3s/manifests/web/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: web + namespace: honeydue + labels: + app.kubernetes.io/name: web + app.kubernetes.io/part-of: honeydue +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: web + ports: + - port: 3000 + targetPort: 3000 + protocol: TCP diff --git a/deploy-k3s/scripts/03-deploy.sh b/deploy-k3s/scripts/03-deploy.sh index 7cef4b4..1547a45 100755 --- a/deploy-k3s/scripts/03-deploy.sh +++ b/deploy-k3s/scripts/03-deploy.sh @@ -58,6 +58,22 @@ REGISTRY_PREFIX="${REGISTRY_SERVER%/}/${REGISTRY_NS#/}" API_IMAGE="${REGISTRY_PREFIX}/honeydue-api:${DEPLOY_TAG}" WORKER_IMAGE="${REGISTRY_PREFIX}/honeydue-worker:${DEPLOY_TAG}" ADMIN_IMAGE="${REGISTRY_PREFIX}/honeydue-admin:${DEPLOY_TAG}" +WEB_IMAGE="${REGISTRY_PREFIX}/honeydue-web:${DEPLOY_TAG}" + +# The web client lives in a sibling repo. Resolve its path once. +WEB_REPO_DIR="$(cd "${REPO_DIR}/../honeyDueAPI-Web" 2>/dev/null && pwd || echo "")" + +# NEXT_PUBLIC_* is baked into client bundles at build time, so API/PostHog +# URLs must be passed as build-args — setting them at pod runtime has no +# effect on already-bundled JS. +API_DOMAIN="$(cfg_require domains.api "API domain")" +ADMIN_API_URL="https://${API_DOMAIN}" +WEB_API_URL="https://${API_DOMAIN}/api" + +# PostHog keys for the web client are optional — read from operator shell +# env so they never land in a committed file. Empty disables analytics. +: "${NEXT_PUBLIC_POSTHOG_KEY:=}" +: "${NEXT_PUBLIC_POSTHOG_HOST:=}" # --- Build and push --- @@ -71,13 +87,27 @@ if [[ "${SKIP_BUILD}" == "false" ]]; then log "Building Worker image: ${WORKER_IMAGE}" docker build --target worker -t "${WORKER_IMAGE}" "${REPO_DIR}" - log "Building Admin image: ${ADMIN_IMAGE}" - docker build --target admin -t "${ADMIN_IMAGE}" "${REPO_DIR}" + log "Building Admin image: ${ADMIN_IMAGE} (NEXT_PUBLIC_API_URL=${ADMIN_API_URL})" + docker build --target admin \ + --build-arg "NEXT_PUBLIC_API_URL=${ADMIN_API_URL}" \ + -t "${ADMIN_IMAGE}" "${REPO_DIR}" + + if [[ -n "${WEB_REPO_DIR}" && -f "${WEB_REPO_DIR}/Dockerfile" ]]; then + log "Building Web image: ${WEB_IMAGE} (NEXT_PUBLIC_API_URL=${WEB_API_URL})" + docker build \ + --build-arg "NEXT_PUBLIC_API_URL=${WEB_API_URL}" \ + --build-arg "NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}" \ + --build-arg "NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}" \ + -t "${WEB_IMAGE}" "${WEB_REPO_DIR}" + else + warn "honeyDueAPI-Web sibling repo not found at ${WEB_REPO_DIR:-}; skipping web build" + fi log "Pushing images..." docker push "${API_IMAGE}" docker push "${WORKER_IMAGE}" docker push "${ADMIN_IMAGE}" + [[ -n "${WEB_REPO_DIR}" && -f "${WEB_REPO_DIR}/Dockerfile" ]] && docker push "${WEB_IMAGE}" # Also tag and push :latest docker tag "${API_IMAGE}" "${REGISTRY_PREFIX}/honeydue-api:latest" @@ -86,6 +116,10 @@ if [[ "${SKIP_BUILD}" == "false" ]]; then docker push "${REGISTRY_PREFIX}/honeydue-api:latest" docker push "${REGISTRY_PREFIX}/honeydue-worker:latest" docker push "${REGISTRY_PREFIX}/honeydue-admin:latest" + if [[ -n "${WEB_REPO_DIR}" && -f "${WEB_REPO_DIR}/Dockerfile" ]]; then + docker tag "${WEB_IMAGE}" "${REGISTRY_PREFIX}/honeydue-web:latest" + docker push "${REGISTRY_PREFIX}/honeydue-web:latest" + fi else warn "Skipping build. Using images for tag: ${DEPLOY_TAG}" fi @@ -121,6 +155,11 @@ sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_IMAGE}|" "${MANIFESTS}/worker/de sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_IMAGE}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f - kubectl apply -f "${MANIFESTS}/admin/service.yaml" +if [[ -d "${MANIFESTS}/web" ]]; then + sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_IMAGE}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f - + kubectl apply -f "${MANIFESTS}/web/service.yaml" +fi + # --- Wait for rollouts --- log "Waiting for rollouts..." @@ -129,6 +168,9 @@ kubectl rollout status deployment/redis -n "${NAMESPACE}" --timeout=120s kubectl rollout status deployment/api -n "${NAMESPACE}" --timeout=300s kubectl rollout status deployment/worker -n "${NAMESPACE}" --timeout=300s kubectl rollout status deployment/admin -n "${NAMESPACE}" --timeout=300s +if [[ -d "${MANIFESTS}/web" ]]; then + kubectl rollout status deployment/web -n "${NAMESPACE}" --timeout=300s +fi # --- Done --- @@ -139,5 +181,6 @@ log "Images:" log " API: ${API_IMAGE}" log " Worker: ${WORKER_IMAGE}" log " Admin: ${ADMIN_IMAGE}" +[[ -d "${MANIFESTS}/web" ]] && log " Web: ${WEB_IMAGE}" log "" log "Run ./scripts/04-verify.sh to check cluster health." diff --git a/deploy-k3s/scripts/_config.sh b/deploy-k3s/scripts/_config.sh index cb74f99..09fd558 100755 --- a/deploy-k3s/scripts/_config.sh +++ b/deploy-k3s/scripts/_config.sh @@ -107,6 +107,8 @@ lines = [ # Admin f\"NEXT_PUBLIC_API_URL=https://{d['api']}\", f\"ADMIN_PANEL_URL=https://{d['admin']}\", + # Web (app.myhoneydue.com) — server-side proxy target; browser never sees this + f\"API_URL=https://{d['api']}/api\", # Database f\"DB_HOST={val(db['host'])}\", f\"DB_PORT={db['port']}\",