From 4743741050b421b34738297de87f6f8434acdb3d Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Thu, 22 Oct 2026 16:42:26 +0800 Subject: [PATCH 1/2] feat(frontend): add local health endpoints and Helm probes --- frontend/README.md | 41 +++++ frontend/desktop/.env.template | 8 +- .../desktop-frontend-values.yaml | 32 ++++ .../templates/deployment.yaml | 4 +- .../templates/tests/test-connection.yaml | 17 ++ .../charts/desktop-frontend/values.yaml | 49 ++++-- frontend/desktop/src/pages/healthz.ts | 56 +++++++ frontend/desktop/src/utils/healthz.ts | 56 +++++++ .../costcenter-frontend-values.yaml | 31 ++++ .../templates/deployment.yaml | 15 +- .../templates/tests/test-connection.yaml | 17 ++ .../charts/costcenter-frontend/values.yaml | 31 ++++ .../providers/costcenter/src/pages/_app.tsx | 6 + .../providers/costcenter/src/pages/healthz.ts | 56 +++++++ .../providers/costcenter/src/utils/healthz.ts | 9 + frontend/providers/license/.env.template | 7 +- .../license-frontend-values.yaml | 31 ++++ .../templates/deployment.yaml | 15 +- .../templates/tests/test-connection.yaml | 17 ++ .../charts/license-frontend/values.yaml | 31 ++++ .../providers/license/src/pages/healthz.ts | 56 +++++++ .../providers/license/src/utils/healthz.ts | 15 ++ frontend/scripts/healthz.test.cjs | 154 ++++++++++++++++++ 23 files changed, 716 insertions(+), 38 deletions(-) create mode 100644 frontend/desktop/deploy/charts/desktop-frontend/templates/tests/test-connection.yaml create mode 100644 frontend/desktop/src/pages/healthz.ts create mode 100644 frontend/desktop/src/utils/healthz.ts create mode 100644 frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/tests/test-connection.yaml create mode 100644 frontend/providers/costcenter/src/pages/healthz.ts create mode 100644 frontend/providers/costcenter/src/utils/healthz.ts create mode 100644 frontend/providers/license/deploy/charts/license-frontend/templates/tests/test-connection.yaml create mode 100644 frontend/providers/license/src/pages/healthz.ts create mode 100644 frontend/providers/license/src/utils/healthz.ts create mode 100644 frontend/scripts/healthz.test.cjs diff --git a/frontend/README.md b/frontend/README.md index 515a4cb4d661..4e1c38345d63 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -112,3 +112,44 @@ Refer to other apps to add some configuration. In order to support multiple namespaces, the method of obtaining 'namespace' in the backend should be replaced with the method of obtaining it from 'kubeconfig' instead of adding 'ns-' to 'user' for generation purpose. + +## Health probes + +Desktop, Costcenter and License expose root `GET /healthz` (and `HEAD`). A ready +process returns HTTP 200; missing local configuration returns HTTP 503. Responses +are not cached. These routes require no user credentials and do not query databases, +Kubernetes, billing APIs or other external services. They are not business-health checks. + +- Desktop checks `/app/data/config.yaml` in production, or `CONFIG_PATH` (default + `data/config.local.yaml`) in development: cloud domain/region, database URI strings + and internal/regional/global JWT keys must be present. +- Costcenter checks that instrumentation has installed its validated `__APP_CONFIG__`. + It does not reload configuration or terminate the process from the health route. +- License requires nonempty `SEALOS_DOMAIN`, `LICENSE_DOMAIN` and `MONGODB_URI`. + These existing variables are injected from chart configuration and the generated Secret. + +All three charts expose `startupProbe`, `readinessProbe`, `livenessProbe` and +`tests.path` in both `values.yaml` and the installation `*-values.yaml`. Defaults: + +| App | Startup (period / timeout / failures) | Readiness | Liveness | +| --- | --- | --- | --- | +| Desktop | 2s / 3s / 60 | delay 5s, period 3s, timeout 2s, successes 3, failures 3 | delay 15s, period 20s, timeout 1s, failures 3 | +| Costcenter | 2s / 3s / 30 | period 10s, timeout 3s, failures 6 | period 20s, timeout 3s, failures 3 | +| License | 5s / 5s / 30 | period 5s, timeout 5s, failures 3 | period 20s, timeout 5s, failures 3 | + +Startup success enables readiness/liveness checks. Readiness gates Service traffic; +liveness failure restarts the container. Override the objects in the installation +values file or with Helm `-f` / `--set startupProbe.failureThreshold=90`. There are +no new probe environment variables. Keep the path `/healthz` and the container port +3000 unless changing the application's actual listener. `tests.image` defaults to +`busybox:1.36.1`; `tests.path` defaults to `/healthz`. Run `helm test -n + --logs` to check the Service endpoint. + +Release the application image and updated chart together: older images do not have +`/healthz`. Roll back both image and probe configuration together. Existing Helm +releases with retained values must explicitly update their old probe paths. Desktop's +migration init container does not receive HTTP probes; do not run migrations as part +of health verification. + +Run `node --test frontend/scripts/healthz.test.cjs` from the repository root for +isolated endpoint and local-configuration regression tests. diff --git a/frontend/desktop/.env.template b/frontend/desktop/.env.template index d561908ee798..eb190cd33d88 100644 --- a/frontend/desktop/.env.template +++ b/frontend/desktop/.env.template @@ -25,4 +25,10 @@ SEALOS_CLOUD_DOMAIN="cloud.sealos.io" # costcenter STRIPE_ENABLED= STRIPE_PUB= -SEALOS_CLOUD_DOMAIN= \ No newline at end of file +SEALOS_CLOUD_DOMAIN= +# GET /healthz checks local configuration only; it never connects to a database. +# Probe timings/path are Helm startupProbe/readinessProbe/livenessProbe values, +# not environment variables. See frontend/README.md (Health probes). +# Production config: /app/data/config.yaml (mounted by the chart). +# Development config: CONFIG_PATH, default data/config.local.yaml. +# CONFIG_PATH=data/config.local.yaml diff --git a/frontend/desktop/deploy/charts/desktop-frontend/desktop-frontend-values.yaml b/frontend/desktop/deploy/charts/desktop-frontend/desktop-frontend-values.yaml index a9882daafc5d..152ff126e9ad 100644 --- a/frontend/desktop/deploy/charts/desktop-frontend/desktop-frontend-values.yaml +++ b/frontend/desktop/deploy/charts/desktop-frontend/desktop-frontend-values.yaml @@ -197,3 +197,35 @@ desktopConfig: # Team management maxTeamCount: 0 maxTeamMemberCount: 0 + +# App-local health checks; override these objects with Helm -f/--set. +# Startup protects initialization; readiness gates traffic; liveness restarts hung containers. +startupProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 2 + timeoutSeconds: 3 + failureThreshold: 60 + +readinessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 3 + timeoutSeconds: 2 + successThreshold: 3 + failureThreshold: 3 + +livenessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 20 + +# helm test uses the same app-local health endpoint. +tests: + image: busybox:1.36.1 + path: /healthz diff --git a/frontend/desktop/deploy/charts/desktop-frontend/templates/deployment.yaml b/frontend/desktop/deploy/charts/desktop-frontend/templates/deployment.yaml index c241ec1b9353..8ab839d4033f 100644 --- a/frontend/desktop/deploy/charts/desktop-frontend/templates/deployment.yaml +++ b/frontend/desktop/deploy/charts/desktop-frontend/templates/deployment.yaml @@ -70,11 +70,13 @@ spec: REGION_DATABASE_URL=$(cat /app/data/config.yaml | awk '/regionalCockroachdbURI:/ {print $2}' | tr -d '"') \ GLOBAL_DATABASE_URL=$(cat /app/data/config.yaml | awk '/globalCockroachdbURI:/ {print $2}' | tr -d '"') \ node /app/desktop/server.js + image: "{{ .Values.image }}" + startupProbe: + {{- toYaml .Values.startupProbe | nindent 12 }} readinessProbe: {{- toYaml .Values.readinessProbe | nindent 12 }} livenessProbe: {{- toYaml .Values.livenessProbe | nindent 12 }} - image: "{{ .Values.image }}" imagePullPolicy: {{ .Values.imagePullPolicy }} volumeMounts: - mountPath: /app/data/config.yaml diff --git a/frontend/desktop/deploy/charts/desktop-frontend/templates/tests/test-connection.yaml b/frontend/desktop/deploy/charts/desktop-frontend/templates/tests/test-connection.yaml new file mode 100644 index 000000000000..8487f2c3491f --- /dev/null +++ b/frontend/desktop/deploy/charts/desktop-frontend/templates/tests/test-connection.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "desktop.fullname" . }}-test-connection + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation +spec: + restartPolicy: Never + containers: + - name: wget + image: {{ .Values.tests.image | quote }} + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-ec"] + args: + - wget -q -T 10 -O /dev/null "http://{{ include "desktop.fullname" . }}:{{ .Values.service.port }}{{ .Values.tests.path }}" diff --git a/frontend/desktop/deploy/charts/desktop-frontend/values.yaml b/frontend/desktop/deploy/charts/desktop-frontend/values.yaml index c22ffc56ea81..470c6074bb91 100644 --- a/frontend/desktop/deploy/charts/desktop-frontend/values.yaml +++ b/frontend/desktop/deploy/charts/desktop-frontend/values.yaml @@ -101,23 +101,6 @@ desktopConfig: # End of auto-configured values # ============================================================================ -livenessProbe: - httpGet: - path: /api/platform/getAppConfig - port: 3000 - initialDelaySeconds: 15 - periodSeconds: 20 - -readinessProbe: - httpGet: - path: /api/platform/getAppConfig - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 3 - timeoutSeconds: 2 - successThreshold: 3 - failureThreshold: 3 - # Ingress configuration ingress: enabled: true @@ -134,3 +117,35 @@ nodeSelector: {} tolerations: [] affinity: {} + +# App-local health checks; override these objects with Helm -f/--set. +# Startup protects initialization; readiness gates traffic; liveness restarts hung containers. +startupProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 2 + timeoutSeconds: 3 + failureThreshold: 60 + +readinessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 3 + timeoutSeconds: 2 + successThreshold: 3 + failureThreshold: 3 + +livenessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 20 + +# helm test uses the same app-local health endpoint. +tests: + image: busybox:1.36.1 + path: /healthz diff --git a/frontend/desktop/src/pages/healthz.ts b/frontend/desktop/src/pages/healthz.ts new file mode 100644 index 000000000000..a1658827588b --- /dev/null +++ b/frontend/desktop/src/pages/healthz.ts @@ -0,0 +1,56 @@ +import type { GetServerSideProps, GetServerSidePropsContext } from 'next'; +import { assertReady, HEALTHZ_SERVICE } from '../utils/healthz'; + +export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'GET') { + if (req.method === 'HEAD') { + return sendHealthz(res, true); + } + + res.setHeader('Allow', 'GET, HEAD'); + sendJson(res, 405, { error: 'Method Not Allowed' }); + return { props: {} }; + } + + return sendHealthz(res); +}; + +function sendHealthz(res: GetServerSidePropsContext['res'], head = false) { + try { + assertReady(); + } catch (error) { + console.error(`[healthz] ${HEALTHZ_SERVICE} is not ready`, error); + if (head) { + res.statusCode = 503; + res.end(); + } else { + sendJson(res, 503, { status: 'error', service: HEALTHZ_SERVICE }); + } + return { props: {} }; + } + + if (head) { + res.statusCode = 200; + res.end(); + } else { + sendJson(res, 200, { status: 'ok', service: HEALTHZ_SERVICE }); + } + + return { props: {} }; +} + +function sendJson( + res: GetServerSidePropsContext['res'], + statusCode: number, + body: Record +) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +} + +export default function Healthz() { + return null; +} diff --git a/frontend/desktop/src/utils/healthz.ts b/frontend/desktop/src/utils/healthz.ts new file mode 100644 index 000000000000..04ff9d0df8d9 --- /dev/null +++ b/frontend/desktop/src/utils/healthz.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'fs'; +import yaml from 'js-yaml'; +type ConfigRecord = Record; + +export const HEALTHZ_SERVICE = 'desktop'; + +export function assertReady() { + const config = loadConfig(); + const cloud = readRecord(config, 'cloud'); + const database = readRecord(config, 'database'); + const desktop = readRecord(config, 'desktop'); + const auth = readRecord(desktop, 'auth'); + const jwt = readRecord(auth, 'jwt'); + + readNonEmptyString(cloud, 'domain'); + readNonEmptyString(cloud, 'regionUID'); + readNonEmptyString(database, 'globalCockroachdbURI'); + readNonEmptyString(database, 'regionalCockroachdbURI'); + readNonEmptyString(jwt, 'internal'); + readNonEmptyString(jwt, 'regional'); + readNonEmptyString(jwt, 'global'); +} + +function loadConfig(): ConfigRecord { + const filename = + process.env.NODE_ENV === 'development' + ? process.env.CONFIG_PATH || 'data/config.local.yaml' + : '/app/data/config.yaml'; + const config = yaml.load(readFileSync(filename, 'utf-8')); + + if (!isRecord(config)) { + throw new Error(`${filename} is not a YAML object`); + } + + return config; +} + +function readRecord(record: ConfigRecord, key: string): ConfigRecord { + const value = record[key]; + if (!isRecord(value)) { + throw new Error(`${key} is not configured`); + } + return value; +} + +function readNonEmptyString(record: ConfigRecord, key: string): string { + const value = record[key]; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${key} is not configured`); + } + return value; +} + +function isRecord(value: unknown): value is ConfigRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/costcenter-frontend-values.yaml b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/costcenter-frontend-values.yaml index fb4e98c1d336..006be51b4670 100644 --- a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/costcenter-frontend-values.yaml +++ b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/costcenter-frontend-values.yaml @@ -47,3 +47,34 @@ costcenterConfig: components: accountService: url: "http://account-service.account-system.svc:2333" + +# App-local health checks; override these objects with Helm -f/--set. +# Startup protects initialization; readiness gates traffic; liveness restarts hung containers. +startupProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 2 + timeoutSeconds: 3 + failureThreshold: 30 + +readinessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + +livenessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + +# helm test uses the same app-local health endpoint. +tests: + image: busybox:1.36.1 + path: /healthz diff --git a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/deployment.yaml b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/deployment.yaml index df151fa11ea2..b3ef7129ebd2 100644 --- a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/deployment.yaml +++ b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/deployment.yaml @@ -31,21 +31,18 @@ spec: capabilities: drop: - "ALL" + startupProbe: + {{- toYaml .Values.startupProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} image: "{{ .Values.image }}" imagePullPolicy: {{ .Values.imagePullPolicy }} volumeMounts: - name: costcenter-frontend-volume mountPath: /app/data/config.yaml subPath: config.yaml - readinessProbe: - httpGet: - path: /api/platform/getAppConfig - port: {{ .Values.service.port }} - initialDelaySeconds: 5 - periodSeconds: 3 - timeoutSeconds: 2 - successThreshold: 3 - failureThreshold: 3 {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/tests/test-connection.yaml b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/tests/test-connection.yaml new file mode 100644 index 000000000000..35692483e80b --- /dev/null +++ b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/templates/tests/test-connection.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "costcenter-frontend.fullname" . }}-test-connection + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation +spec: + restartPolicy: Never + containers: + - name: wget + image: {{ .Values.tests.image | quote }} + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-ec"] + args: + - wget -q -T 10 -O /dev/null "http://{{ include "costcenter-frontend.fullname" . }}:{{ .Values.service.port }}{{ .Values.tests.path }}" diff --git a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/values.yaml b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/values.yaml index 287880bf50f7..fcb1e0bd8a04 100644 --- a/frontend/providers/costcenter/deploy/charts/costcenter-frontend/values.yaml +++ b/frontend/providers/costcenter/deploy/charts/costcenter-frontend/values.yaml @@ -79,3 +79,34 @@ affinity: values: - costcenter-frontend topologyKey: "kubernetes.io/hostname" + +# App-local health checks; override these objects with Helm -f/--set. +# Startup protects initialization; readiness gates traffic; liveness restarts hung containers. +startupProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 2 + timeoutSeconds: 3 + failureThreshold: 30 + +readinessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + +livenessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + +# helm test uses the same app-local health endpoint. +tests: + image: busybox:1.36.1 + path: /healthz diff --git a/frontend/providers/costcenter/src/pages/_app.tsx b/frontend/providers/costcenter/src/pages/_app.tsx index f56784f352a5..fb420da39768 100644 --- a/frontend/providers/costcenter/src/pages/_app.tsx +++ b/frontend/providers/costcenter/src/pages/_app.tsx @@ -160,6 +160,12 @@ const MyApp = ({ Component, pageProps, dehydratedState }: AppProps & AppOwnProps ); MyApp.getInitialProps = async (context: AppContext): Promise => { + // Health probes must not prefetch business configuration or call Config(), + // which terminates the process when instrumentation has not initialized it. + if (context.ctx.pathname === '/healthz') { + return { pageProps: {} }; + } + const ctx = await App.getInitialProps(context); let dehydratedState: unknown; diff --git a/frontend/providers/costcenter/src/pages/healthz.ts b/frontend/providers/costcenter/src/pages/healthz.ts new file mode 100644 index 000000000000..a1658827588b --- /dev/null +++ b/frontend/providers/costcenter/src/pages/healthz.ts @@ -0,0 +1,56 @@ +import type { GetServerSideProps, GetServerSidePropsContext } from 'next'; +import { assertReady, HEALTHZ_SERVICE } from '../utils/healthz'; + +export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'GET') { + if (req.method === 'HEAD') { + return sendHealthz(res, true); + } + + res.setHeader('Allow', 'GET, HEAD'); + sendJson(res, 405, { error: 'Method Not Allowed' }); + return { props: {} }; + } + + return sendHealthz(res); +}; + +function sendHealthz(res: GetServerSidePropsContext['res'], head = false) { + try { + assertReady(); + } catch (error) { + console.error(`[healthz] ${HEALTHZ_SERVICE} is not ready`, error); + if (head) { + res.statusCode = 503; + res.end(); + } else { + sendJson(res, 503, { status: 'error', service: HEALTHZ_SERVICE }); + } + return { props: {} }; + } + + if (head) { + res.statusCode = 200; + res.end(); + } else { + sendJson(res, 200, { status: 'ok', service: HEALTHZ_SERVICE }); + } + + return { props: {} }; +} + +function sendJson( + res: GetServerSidePropsContext['res'], + statusCode: number, + body: Record +) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +} + +export default function Healthz() { + return null; +} diff --git a/frontend/providers/costcenter/src/utils/healthz.ts b/frontend/providers/costcenter/src/utils/healthz.ts new file mode 100644 index 000000000000..86dcc9a9df2e --- /dev/null +++ b/frontend/providers/costcenter/src/utils/healthz.ts @@ -0,0 +1,9 @@ +export const HEALTHZ_SERVICE = 'costcenter'; + +export function assertReady() { + // Instrumentation validates and installs the configuration before serving requests. + // Do not call Config(): it exits the process when initialization is incomplete. + if (!globalThis.__APP_CONFIG__) { + throw new Error('Application configuration is not initialized'); + } +} diff --git a/frontend/providers/license/.env.template b/frontend/providers/license/.env.template index efe71101daeb..24ee7f39cb3c 100644 --- a/frontend/providers/license/.env.template +++ b/frontend/providers/license/.env.template @@ -3,4 +3,9 @@ SEALOS_DOMAIN="cloud.sealos.io" LICENSE_DOMAIN="dev.sealos.top" MONGODB_URI= # Same as desktop important -# PASSWORD_SALT= \ No newline at end of file +# PASSWORD_SALT= +# GET /healthz checks local configuration only; it never connects to a database. +# Probe timings/path are Helm startupProbe/readinessProbe/livenessProbe values, +# not environment variables. See frontend/README.md (Health probes). +# Required: SEALOS_DOMAIN, LICENSE_DOMAIN and MONGODB_URI must be nonempty. +# Deployment injects these from licenseConfig and its generated Secret. diff --git a/frontend/providers/license/deploy/charts/license-frontend/license-frontend-values.yaml b/frontend/providers/license/deploy/charts/license-frontend/license-frontend-values.yaml index 63f0bff71e3c..635f9e9aa10e 100644 --- a/frontend/providers/license/deploy/charts/license-frontend/license-frontend-values.yaml +++ b/frontend/providers/license/deploy/charts/license-frontend/license-frontend-values.yaml @@ -15,3 +15,34 @@ resources: # Customize these settings based on your requirements licenseConfig: licensePurchaseDomain: "license.sealos.io" + +# App-local health checks; override these objects with Helm -f/--set. +# Startup protects initialization; readiness gates traffic; liveness restarts hung containers. +startupProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + +readinessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + +livenessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + +# helm test uses the same app-local health endpoint. +tests: + image: busybox:1.36.1 + path: /healthz diff --git a/frontend/providers/license/deploy/charts/license-frontend/templates/deployment.yaml b/frontend/providers/license/deploy/charts/license-frontend/templates/deployment.yaml index ffb2fba5a69c..1627135b0755 100644 --- a/frontend/providers/license/deploy/charts/license-frontend/templates/deployment.yaml +++ b/frontend/providers/license/deploy/charts/license-frontend/templates/deployment.yaml @@ -42,17 +42,14 @@ spec: - 'ALL' resources: {{- toYaml .Values.resources | nindent 12 }} + startupProbe: + {{- toYaml .Values.startupProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} image: "{{ .Values.image }}" imagePullPolicy: {{ .Values.imagePullPolicy }} - readinessProbe: - httpGet: - path: /api/cronjob/init - port: {{ .Values.service.port }} - initialDelaySeconds: 5 - periodSeconds: 3 - timeoutSeconds: 3 - successThreshold: 1 - failureThreshold: 3 volumeMounts: - name: license-frontend-volume mountPath: /config.yaml diff --git a/frontend/providers/license/deploy/charts/license-frontend/templates/tests/test-connection.yaml b/frontend/providers/license/deploy/charts/license-frontend/templates/tests/test-connection.yaml new file mode 100644 index 000000000000..0e4bbdde7f97 --- /dev/null +++ b/frontend/providers/license/deploy/charts/license-frontend/templates/tests/test-connection.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "license-frontend.fullname" . }}-test-connection + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation +spec: + restartPolicy: Never + containers: + - name: wget + image: {{ .Values.tests.image | quote }} + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-ec"] + args: + - wget -q -T 10 -O /dev/null "http://{{ include "license-frontend.fullname" . }}:{{ .Values.service.port }}{{ .Values.tests.path }}" diff --git a/frontend/providers/license/deploy/charts/license-frontend/values.yaml b/frontend/providers/license/deploy/charts/license-frontend/values.yaml index 14291e22496d..0f5c40132d83 100644 --- a/frontend/providers/license/deploy/charts/license-frontend/values.yaml +++ b/frontend/providers/license/deploy/charts/license-frontend/values.yaml @@ -59,3 +59,34 @@ affinity: values: - license-frontend topologyKey: "kubernetes.io/hostname" + +# App-local health checks; override these objects with Helm -f/--set. +# Startup protects initialization; readiness gates traffic; liveness restarts hung containers. +startupProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + +readinessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + +livenessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + +# helm test uses the same app-local health endpoint. +tests: + image: busybox:1.36.1 + path: /healthz diff --git a/frontend/providers/license/src/pages/healthz.ts b/frontend/providers/license/src/pages/healthz.ts new file mode 100644 index 000000000000..a1658827588b --- /dev/null +++ b/frontend/providers/license/src/pages/healthz.ts @@ -0,0 +1,56 @@ +import type { GetServerSideProps, GetServerSidePropsContext } from 'next'; +import { assertReady, HEALTHZ_SERVICE } from '../utils/healthz'; + +export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'GET') { + if (req.method === 'HEAD') { + return sendHealthz(res, true); + } + + res.setHeader('Allow', 'GET, HEAD'); + sendJson(res, 405, { error: 'Method Not Allowed' }); + return { props: {} }; + } + + return sendHealthz(res); +}; + +function sendHealthz(res: GetServerSidePropsContext['res'], head = false) { + try { + assertReady(); + } catch (error) { + console.error(`[healthz] ${HEALTHZ_SERVICE} is not ready`, error); + if (head) { + res.statusCode = 503; + res.end(); + } else { + sendJson(res, 503, { status: 'error', service: HEALTHZ_SERVICE }); + } + return { props: {} }; + } + + if (head) { + res.statusCode = 200; + res.end(); + } else { + sendJson(res, 200, { status: 'ok', service: HEALTHZ_SERVICE }); + } + + return { props: {} }; +} + +function sendJson( + res: GetServerSidePropsContext['res'], + statusCode: number, + body: Record +) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +} + +export default function Healthz() { + return null; +} diff --git a/frontend/providers/license/src/utils/healthz.ts b/frontend/providers/license/src/utils/healthz.ts new file mode 100644 index 000000000000..53de64b41841 --- /dev/null +++ b/frontend/providers/license/src/utils/healthz.ts @@ -0,0 +1,15 @@ +export const HEALTHZ_SERVICE = 'license'; + +export function assertReady() { + readNonEmptyEnv('SEALOS_DOMAIN'); + readNonEmptyEnv('LICENSE_DOMAIN'); + readNonEmptyEnv('MONGODB_URI'); +} + +function readNonEmptyEnv(name: string): string { + const value = process.env[name]; + if (!value || value.trim() === '') { + throw new Error(`${name} is not configured`); + } + return value; +} diff --git a/frontend/scripts/healthz.test.cjs b/frontend/scripts/healthz.test.cjs new file mode 100644 index 000000000000..5d548049183d --- /dev/null +++ b/frontend/scripts/healthz.test.cjs @@ -0,0 +1,154 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const apps = ['desktop', 'providers/costcenter', 'providers/license']; +function load(app, file, mocks = {}, globals = {}) { + const filename = path.resolve(__dirname, '..', app, 'src', file); + const output = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true + } + }).outputText; + const exports = {}; + vm.runInNewContext( + output, + { + exports, + require(name) { + assert.ok(Object.hasOwn(mocks, name), `Unexpected dependency: ${name}`); + return mocks[name]; + }, + console: { error() {} }, + ...globals + }, + { filename } + ); + return exports; +} + +for (const app of apps) { + test(`${app}: health HTTP contract without external dependencies`, async () => { + let ready = true; + const route = load(app, 'pages/healthz.ts', { + '../utils/healthz': { + HEALTHZ_SERVICE: app.split('/').at(-1), + assertReady() { + if (!ready) throw new Error('private configuration detail'); + } + } + }); + for (const state of [true, false]) { + ready = state; + for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'DELETE']) { + const headers = {}; + const res = { + statusCode: 200, + setHeader(k, v) { + headers[k] = v; + }, + end(body) { + this.body = body; + this.ended = true; + } + }; + await route.getServerSideProps({ req: { method }, res }); + const allowed = method === 'GET' || method === 'HEAD'; + assert.equal(res.statusCode, allowed ? (ready ? 200 : 503) : 405); + assert.equal(headers['Cache-Control'], 'no-store'); + assert.equal(res.ended, true); + if (method === 'HEAD') assert.equal(res.body, undefined); + else { + assert.equal(headers['Content-Type'], 'application/json'); + assert.doesNotMatch(res.body, /private configuration detail/); + if (allowed) assert.equal(JSON.parse(res.body).status, ready ? 'ok' : 'error'); + } + if (!allowed) assert.equal(headers.Allow, 'GET, HEAD'); + } + } + }); +} + +test('desktop: local configuration, malformed/missing fields and path selection', () => { + const config = { + cloud: { domain: 'example.test', regionUID: 'test' }, + database: { + globalCockroachdbURI: 'postgres://unreachable/global', + regionalCockroachdbURI: 'postgres://unreachable/region' + }, + desktop: { auth: { jwt: { internal: 'test', regional: 'test', global: 'test' } } } + }; + for (const env of [ + { NODE_ENV: 'production', CONFIG_PATH: '/ignored' }, + { NODE_ENV: 'development' }, + { NODE_ENV: 'development', CONFIG_PATH: '/custom.yaml' } + ]) { + let data = config; + let missing = false; + const health = load( + 'desktop', + 'utils/healthz.ts', + { + fs: { + readFileSync(filename) { + assert.equal( + filename, + env.NODE_ENV === 'production' + ? '/app/data/config.yaml' + : env.CONFIG_PATH || 'data/config.local.yaml' + ); + if (missing) throw new Error('ENOENT'); + return JSON.stringify(data); + } + }, + 'js-yaml': require('../desktop/node_modules/js-yaml') + }, + { process: { env } } + ); + health.assertReady(); + for (const invalid of [ + null, + [], + {}, + { ...config, cloud: { domain: ' ', regionUID: 'test' } }, + { ...config, database: {} }, + { ...config, desktop: {} } + ]) { + data = invalid; + assert.throws(() => health.assertReady()); + } + missing = true; + assert.throws(() => health.assertReady(), /ENOENT/); + } +}); + +test('costcenter: readiness follows instrumentation without process.exit', () => { + assert.throws( + () => load('providers/costcenter', 'utils/healthz.ts').assertReady(), + /not initialized/ + ); + load('providers/costcenter', 'utils/healthz.ts', {}, { __APP_CONFIG__: {} }).assertReady(); +}); + +test('license: requires local env but does not connect to MongoDB', () => { + const env = { + SEALOS_DOMAIN: 'example.test', + LICENSE_DOMAIN: 'license.example.test', + MONGODB_URI: 'mongodb://unreachable' + }; + const health = load('providers/license', 'utils/healthz.ts', {}, { process: { env } }); + health.assertReady(); + for (const key of Object.keys(env)) { + const saved = env[key]; + for (const value of ['', ' ', undefined]) { + env[key] = value; + assert.throws(() => health.assertReady(), new RegExp(key)); + } + env[key] = saved; + } +}); From c06fbb58308afbca198fa486ce2c0ab66a964aac Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Fri, 23 Oct 2026 17:18:12 +0800 Subject: [PATCH 2/2] fix(frontend): use constant health log format strings --- frontend/desktop/src/pages/healthz.ts | 2 +- frontend/providers/costcenter/src/pages/healthz.ts | 2 +- frontend/providers/license/src/pages/healthz.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/desktop/src/pages/healthz.ts b/frontend/desktop/src/pages/healthz.ts index a1658827588b..a200650f527b 100644 --- a/frontend/desktop/src/pages/healthz.ts +++ b/frontend/desktop/src/pages/healthz.ts @@ -21,7 +21,7 @@ function sendHealthz(res: GetServerSidePropsContext['res'], head = false) { try { assertReady(); } catch (error) { - console.error(`[healthz] ${HEALTHZ_SERVICE} is not ready`, error); + console.error('[healthz] %s is not ready', HEALTHZ_SERVICE, error); if (head) { res.statusCode = 503; res.end(); diff --git a/frontend/providers/costcenter/src/pages/healthz.ts b/frontend/providers/costcenter/src/pages/healthz.ts index a1658827588b..a200650f527b 100644 --- a/frontend/providers/costcenter/src/pages/healthz.ts +++ b/frontend/providers/costcenter/src/pages/healthz.ts @@ -21,7 +21,7 @@ function sendHealthz(res: GetServerSidePropsContext['res'], head = false) { try { assertReady(); } catch (error) { - console.error(`[healthz] ${HEALTHZ_SERVICE} is not ready`, error); + console.error('[healthz] %s is not ready', HEALTHZ_SERVICE, error); if (head) { res.statusCode = 503; res.end(); diff --git a/frontend/providers/license/src/pages/healthz.ts b/frontend/providers/license/src/pages/healthz.ts index a1658827588b..a200650f527b 100644 --- a/frontend/providers/license/src/pages/healthz.ts +++ b/frontend/providers/license/src/pages/healthz.ts @@ -21,7 +21,7 @@ function sendHealthz(res: GetServerSidePropsContext['res'], head = false) { try { assertReady(); } catch (error) { - console.error(`[healthz] ${HEALTHZ_SERVICE} is not ready`, error); + console.error('[healthz] %s is not ready', HEALTHZ_SERVICE, error); if (head) { res.statusCode = 503; res.end();