From fbe0dbac815a3d0249a91ea5c8eabc3bb2ca6a91 Mon Sep 17 00:00:00 2001 From: jklein Date: Sat, 22 Aug 2026 13:18:59 +0200 Subject: [PATCH] Update patchmon to 2.1.3 Chart rewrite for the 2.x single-server architecture: one stateless server deployment (frontend embedded, /api served by the same process), no PVC, single service and ingress path, optional guacd sidecar (disabled by default), 2.x env vocabulary (TRUST_PROXY, readonly role), probes send the public hostname as Host header. Fixes #29: all three reported spots (PVC storageClass guard, RWX assets PVC, frontend nginx 'backend' hostname) are removed by this rewrite. --- ci/patchmon/fixtures.yaml | 76 +--------- ci/patchmon/test-values.yaml | 10 -- patchmon/Chart.yaml | 6 +- .../patchmon-frontend.deployment.yaml | 79 ---------- .../templates/patchmon-frontend.service.yaml | 13 -- ...ployment.yaml => patchmon.deployment.yaml} | 135 +++++++++++++----- patchmon/templates/patchmon.ingress.yaml | 11 +- patchmon/templates/patchmon.pvc.yaml | 13 -- ...end.service.yaml => patchmon.service.yaml} | 4 +- patchmon/values.yaml | 53 ++++--- 10 files changed, 141 insertions(+), 259 deletions(-) delete mode 100644 patchmon/templates/patchmon-frontend.deployment.yaml delete mode 100644 patchmon/templates/patchmon-frontend.service.yaml rename patchmon/templates/{patchmon-backend.deployment.yaml => patchmon.deployment.yaml} (51%) delete mode 100644 patchmon/templates/patchmon.pvc.yaml rename patchmon/templates/{patchmon-backend.service.yaml => patchmon.service.yaml} (57%) diff --git a/ci/patchmon/fixtures.yaml b/ci/patchmon/fixtures.yaml index c548ce1..e8d2bcc 100644 --- a/ci/patchmon/fixtures.yaml +++ b/ci/patchmon/fixtures.yaml @@ -1,77 +1,7 @@ # Fixtures for the patchmon install-test: throwaway postgres + redis, a dex -# OIDC provider, the secrets the 1Password operator would otherwise -# materialize (release name: ci), a pre-bound world-writable hostPath PV for -# the assets volume (the non-root backend writes agent assets at boot; -# hostPath does not support fsGroup), and a Service alias "backend": the -# frontend image's nginx template resolves an upstream host literally named -# "backend" at startup (found during the probes verification of PR #21). -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: ci-static -provisioner: kubernetes.io/no-provisioner ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: node-setup -spec: - backoffLimit: 2 - template: - spec: - restartPolicy: Never - containers: - - name: setup - image: public.ecr.aws/docker/library/busybox:1.36 - command: - - sh - - -c - - > - mkdir -p /pv/patchmon/patchmon-assets /pv/patchmon/patchmon-agents && - chmod -R 0777 /pv/patchmon - volumeMounts: - - name: pv - mountPath: /pv - volumes: - - name: pv - hostPath: - path: /var/lib/ci-pv - type: DirectoryOrCreate ---- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: ci-patchmon-data -spec: - capacity: - storage: 10Gi - accessModes: - # The assets PVC is shared by backend and frontend as ReadWriteMany. - - ReadWriteMany - persistentVolumeReclaimPolicy: Retain - storageClassName: ci-static - claimRef: - namespace: default - name: ci-patchmon-pvc - hostPath: - path: /var/lib/ci-pv/patchmon - type: DirectoryOrCreate ---- -apiVersion: v1 -kind: Service -metadata: - name: backend -spec: - selector: - app: ci-patchmon-backend - ports: - - name: http - port: 80 - targetPort: 3001 - - name: direct - port: 3001 - targetPort: 3001 ---- +# OIDC provider, and the secrets the 1Password operator would otherwise +# materialize (release name: ci). The 2.x server is a single stateless +# container, so no PV/StorageClass fixtures and no service aliases are needed. apiVersion: v1 kind: Secret metadata: diff --git a/ci/patchmon/test-values.yaml b/ci/patchmon/test-values.yaml index ed811c1..3abd0d9 100644 --- a/ci/patchmon/test-values.yaml +++ b/ci/patchmon/test-values.yaml @@ -7,13 +7,3 @@ secrets: ingress: clusterIssuer: ci domain: patchmon.ci.local - -patchmon: - storage: - # Point the RWX assets PVC at the fixture StorageClass so the pre-created - # world-writable hostPath PV is used (local-path cannot provision RWX, and - # the non-root backend must write its assets dir at boot). Note: the PVC - # template guards on .storage.storageClass but renders .storage.class, - # so both keys must be set. - storageClass: ci-static - class: ci-static diff --git a/patchmon/Chart.yaml b/patchmon/Chart.yaml index 8e5ef23..a4d573c 100644 --- a/patchmon/Chart.yaml +++ b/patchmon/Chart.yaml @@ -4,8 +4,8 @@ description: A Helm chart to deploy Patchmon on Kubernetes type: application -version: 4.1.0+up1.4.2 -appVersion: "1.4.2" +version: 5.0.0+up2.1.3 +appVersion: "2.1.3" maintainers: - - name: jklein \ No newline at end of file + - name: jklein diff --git a/patchmon/templates/patchmon-frontend.deployment.yaml b/patchmon/templates/patchmon-frontend.deployment.yaml deleted file mode 100644 index b401b4f..0000000 --- a/patchmon/templates/patchmon-frontend.deployment.yaml +++ /dev/null @@ -1,79 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-frontend - labels: - app: {{ .Release.Name }}-{{ .Chart.Name }}-frontend -spec: - replicas: {{ include "patchmon.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.patchmon.frontend.replicas) }} - selector: - matchLabels: - app: {{ .Release.Name }}-{{ .Chart.Name }}-frontend - template: - metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-frontend - labels: - app: {{ .Release.Name }}-{{ .Chart.Name }}-frontend - spec: - automountServiceAccountToken: false - securityContext: - {{- toYaml .Values.patchmon.frontend.securityContext.pod | nindent 8 }} - containers: - - name: {{ .Release.Name }}-{{ .Chart.Name }}-frontend - image: "ghcr.io/patchmon/patchmon-frontend:{{ .Chart.AppVersion }}" - imagePullPolicy: IfNotPresent - securityContext: - {{- toYaml .Values.patchmon.frontend.securityContext.container | nindent 12 }} - ports: - - containerPort: 3000 - protocol: TCP - name: http - env: - - name: BACKEND_URL - value: "{{ .Release.Name }}-{{ .Chart.Name }}-backend-service" - - name: BACKEND_PORT - value: "80" - resources: - {{- include "patchmon.resources" .Values.patchmon.frontend.resources | nindent 12 }} - livenessProbe: - httpGet: - path: / - port: 3000 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: / - port: 3000 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. - startupProbe: - httpGet: - path: / - port: 3000 - periodSeconds: 5 - timeoutSeconds: 5 - failureThreshold: 30 - volumeMounts: - - name: patchmon-volume - mountPath: /usr/share/nginx/html/assets - subPath: patchmon-assets - # nginx-unprivileged keeps its pid file and temp dirs in /tmp, and - # its entrypoint renders config templates into /etc/nginx/conf.d; - # both must be writable with a read-only root filesystem. - - name: tmp - mountPath: /tmp - - name: nginx-conf-d - mountPath: /etc/nginx/conf.d - restartPolicy: Always - volumes: - - name: tmp - emptyDir: {} - - name: nginx-conf-d - emptyDir: {} - - name: patchmon-volume - persistentVolumeClaim: - claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc \ No newline at end of file diff --git a/patchmon/templates/patchmon-frontend.service.yaml b/patchmon/templates/patchmon-frontend.service.yaml deleted file mode 100644 index 6b1f685..0000000 --- a/patchmon/templates/patchmon-frontend.service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-frontend-service -spec: - selector: - app: {{ .Release.Name }}-{{ .Chart.Name }}-frontend - ports: - - protocol: TCP - port: 80 - targetPort: http - name: http - type: ClusterIP diff --git a/patchmon/templates/patchmon-backend.deployment.yaml b/patchmon/templates/patchmon.deployment.yaml similarity index 51% rename from patchmon/templates/patchmon-backend.deployment.yaml rename to patchmon/templates/patchmon.deployment.yaml index 4646209..c4ec1cf 100644 --- a/patchmon/templates/patchmon-backend.deployment.yaml +++ b/patchmon/templates/patchmon.deployment.yaml @@ -1,30 +1,37 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-backend + name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment labels: - app: {{ .Release.Name }}-{{ .Chart.Name }}-backend + app: {{ .Release.Name }}-{{ .Chart.Name }} spec: - replicas: {{ include "patchmon.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.patchmon.backend.replicas) }} + replicas: {{ include "patchmon.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.patchmon.server.replicas) }} selector: matchLabels: - app: {{ .Release.Name }}-{{ .Chart.Name }}-backend + app: {{ .Release.Name }}-{{ .Chart.Name }} template: metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-backend + name: {{ .Release.Name }}-{{ .Chart.Name }} labels: - app: {{ .Release.Name }}-{{ .Chart.Name }}-backend + app: {{ .Release.Name }}-{{ .Chart.Name }} spec: automountServiceAccountToken: false + # Pin a stable pod hostname: the server identifies itself in the agent + # presence registry (Redis) by hostname, and a Deployment pod's default + # hostname changes on every rollout, which would leave stale presence + # records behind (upstream pins "patchmon-server" in its compose file + # for the same reason). + hostname: {{ .Release.Name }}-{{ .Chart.Name }}-server securityContext: - fsGroup: 1000 - fsGroupChangePolicy: Always + {{- toYaml .Values.patchmon.server.securityContext.pod | nindent 8 }} containers: - - name: {{ .Release.Name }}-{{ .Chart.Name }}-backend - image: "ghcr.io/patchmon/patchmon-backend:{{ .Chart.AppVersion }}" + - name: {{ .Release.Name }}-{{ .Chart.Name }}-server + image: "ghcr.io/patchmon/patchmon-server:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.patchmon.server.securityContext.container | nindent 12 }} ports: - - containerPort: 3001 + - containerPort: 3000 protocol: TCP name: http env: @@ -58,8 +65,6 @@ spec: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret key: db - - name: ASSETS_DIR - value: /app/assets - name: JWT_SECRET valueFrom: secretKeyRef: @@ -104,8 +109,11 @@ spec: key: requested-scopes - name: OIDC_AUTO_CREATE_USERS value: "true" + # 2.x role vocabulary: superadmin, admin, host_manager, readonly, + # user. "viewer" (the 1.x default of this chart) no longer exists; + # readonly is its least-privilege equivalent. - name: OIDC_DEFAULT_ROLE - value: "viewer" + value: "readonly" - name: OIDC_DISABLE_LOCAL_AUTH value: "true" - name: OIDC_BUTTON_TEXT @@ -125,51 +133,100 @@ spec: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-oidc-secret key: admin-group-name - - name: SERVER_PROTOCOL - value: "https" - - name: SERVER_HOST - value: "{{ .Values.ingress.domain }}" - - name: SERVER_PORT - value: "443" - name: CORS_ORIGIN - value: "https://{{ .Values.ingress.domain }}" - - name: TRUST_PROXIES - value: "1" - # The backend exposes GET /health returning {"status": "ok"}. + value: "https://{{ required "A Domain/Host must be provided" .Values.ingress.domain }}" + # Renamed upstream in 2.x (was TRUST_PROXIES= in 1.x): the + # server sits behind the ingress-nginx reverse proxy. + - name: TRUST_PROXY + value: "true" + {{- if .Values.patchmon.guacd.enabled }} + - name: GUACD_ADDRESS + value: "127.0.0.1:4822" + {{- end }} + # The server exposes GET /health returning {"status": "ok"}. The + # 2.x server rejects requests whose Host header is neither loopback + # nor a CORS_ORIGIN host with 403 — kubelet probes target the pod + # IP, so they must send the public hostname as Host header. livenessProbe: httpGet: path: /health - port: 3001 + port: 3000 + httpHeaders: + - name: Host + value: {{ .Values.ingress.domain }} periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /health - port: 3001 + port: 3000 + httpHeaders: + - name: Host + value: {{ .Values.ingress.domain }} periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 - # The startup probe gates liveness/readiness. The backend waits for - # the database and runs migrations on startup: budget 10s * 30 = 300s. + # The startup probe gates liveness/readiness. The server waits for + # the database and runs migrations on startup; upstream warns that + # the first 2.x boot rebuilds database indexes, which can take + # minutes on slow storage: budget 10s * 60 = 600s. startupProbe: httpGet: path: /health - port: 3001 + port: 3000 + httpHeaders: + - name: Host + value: {{ .Values.ingress.domain }} periodSeconds: 10 timeoutSeconds: 5 + failureThreshold: 60 + resources: + {{- include "patchmon.resources" .Values.patchmon.server.resources | nindent 12 }} + volumeMounts: + # The image is stateless, but the Go server needs a writable /tmp + # for scratch files with a read-only root filesystem. + - name: tmp + mountPath: /tmp + {{- if .Values.patchmon.guacd.enabled }} + - name: {{ .Release.Name }}-{{ .Chart.Name }}-guacd + image: {{ .Values.patchmon.guacd.image }} + imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.patchmon.guacd.securityContext.container | nindent 12 }} + ports: + - containerPort: 4822 + protocol: TCP + name: guacd + livenessProbe: + tcpSocket: + port: 4822 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: 4822 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + tcpSocket: + port: 4822 + periodSeconds: 5 + timeoutSeconds: 5 failureThreshold: 30 resources: - {{- include "patchmon.resources" .Values.patchmon.backend.resources | nindent 12 }} + {{- include "patchmon.resources" .Values.patchmon.guacd.resources | nindent 12 }} volumeMounts: - - mountPath: /app/assets - name: patchmon-volume - subPath: patchmon-assets - - mountPath: /app/agents - name: patchmon-volume - subPath: patchmon-agents + - name: guacd-tmp + mountPath: /tmp + {{- end }} restartPolicy: Always volumes: - - name: patchmon-volume - persistentVolumeClaim: - claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc \ No newline at end of file + - name: tmp + emptyDir: {} + {{- if .Values.patchmon.guacd.enabled }} + - name: guacd-tmp + emptyDir: {} + {{- end }} diff --git a/patchmon/templates/patchmon.ingress.yaml b/patchmon/templates/patchmon.ingress.yaml index 9371dc8..27561f3 100644 --- a/patchmon/templates/patchmon.ingress.yaml +++ b/patchmon/templates/patchmon.ingress.yaml @@ -10,20 +10,15 @@ spec: - host: {{ required "A Domain/Host must be provided" .Values.ingress.domain }} http: paths: + # The 2.x server serves the frontend and /api/* from one process, + # so a single path covers everything (WebSockets included). - backend: service: - name: {{ .Release.Name }}-{{ .Chart.Name }}-frontend-service + name: {{ .Release.Name }}-{{ .Chart.Name }}-service port: name: http path: / pathType: Prefix - - backend: - service: - name: {{ .Release.Name }}-{{ .Chart.Name }}-backend-service - port: - name: http - path: /api - pathType: Prefix tls: - hosts: - {{ required "A Domain/Host must be provided" .Values.ingress.domain }} diff --git a/patchmon/templates/patchmon.pvc.yaml b/patchmon/templates/patchmon.pvc.yaml deleted file mode 100644 index 4c5dd48..0000000 --- a/patchmon/templates/patchmon.pvc.yaml +++ /dev/null @@ -1,13 +0,0 @@ -kind: PersistentVolumeClaim -apiVersion: v1 -metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: {{ .Values.patchmon.storage.size }} - {{- if .Values.patchmon.storage.storageClass }} - storageClassName: {{ .Values.patchmon.storage.class }} - {{- end }} \ No newline at end of file diff --git a/patchmon/templates/patchmon-backend.service.yaml b/patchmon/templates/patchmon.service.yaml similarity index 57% rename from patchmon/templates/patchmon-backend.service.yaml rename to patchmon/templates/patchmon.service.yaml index 5403dac..dc281be 100644 --- a/patchmon/templates/patchmon-backend.service.yaml +++ b/patchmon/templates/patchmon.service.yaml @@ -1,10 +1,10 @@ apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-backend-service + name: {{ .Release.Name }}-{{ .Chart.Name }}-service spec: selector: - app: {{ .Release.Name }}-{{ .Chart.Name }}-backend + app: {{ .Release.Name }}-{{ .Chart.Name }} ports: - protocol: TCP port: 80 diff --git a/patchmon/values.yaml b/patchmon/values.yaml index 6a9c357..a808888 100644 --- a/patchmon/values.yaml +++ b/patchmon/values.yaml @@ -5,24 +5,23 @@ scaleDown: false patchmon: - storage: - size: 10Gi - storageClass: null - frontend: + server: # Replica count for this workload; 0 is allowed and scales it down. # The chart-global scaleDown flag takes precedence. + # Note: the server registers itself in the agent presence registry under a + # stable identity (the pod hostname, pinned by the chart). Running more + # than one replica requires a distinct identity per replica and is not + # supported by this chart. replicas: 1 - # The frontend image is based on nginx-unprivileged; the pod runs as its - # nginx user (uid 101). The image's root-only init script (it would chown - # the shared assets volume) is replaced by fsGroup 1000: the same group - # the backend runs with, so both can read/write the shared assets. + # The 2.x server image is a hardened Alpine image running as uid 65532 + # (nonroot). It is stateless: frontend, agent binaries and SCAP content + # are baked into the image, so the root filesystem stays read-only; /tmp + # is provided as an emptyDir. securityContext: pod: runAsNonRoot: true - runAsUser: 101 - runAsGroup: 101 - fsGroup: 1000 - fsGroupChangePolicy: Always + runAsUser: 65532 + runAsGroup: 65532 container: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -37,20 +36,36 @@ patchmon: limitFactor: 3 requests: cpu: 0.1 - memory: 128Mi + memory: 256Mi ephemeral-storage: 10Mi - backend: - # Replica count for this workload; 0 is allowed and scales it down. - # The chart-global scaleDown flag takes precedence. - replicas: 1 + guacd: + # In-browser RDP for Windows hosts (beta) via the Apache Guacamole daemon, + # run as a sidecar container in the server pod. Disabled by default; when + # enabled, the server is pointed at it via GUACD_ADDRESS=127.0.0.1:4822. + enabled: false + # guacd is an independent upstream application, so its image is not tied + # to the chart's appVersion. Pinned to the version upstream ships with. + image: guacamole/guacd:1.6.0 + # The image runs as its own unprivileged user (uid 1000); it only needs a + # writable /tmp, which the chart provides as an emptyDir. + securityContext: + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: + - ALL resources: # Limits are optional: memory and ephemeral-storage limits default to # limitFactor x the request, an explicitly set limit always wins, and a # CPU limit is only set when configured explicitly under "limits". limitFactor: 3 requests: - cpu: 0.1 - memory: 512Mi + cpu: 0.05 + memory: 64Mi ephemeral-storage: 10Mi ingress: