4
patchmon
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

patchmon

Deploys PatchMon, a patch-status monitor for Linux and Windows hosts, as a single stateless Deployment. The 2.x server serves the frontend and /api/* from one process; frontend assets, agent binaries and SCAP content are baked into the image, so there is no PVC. Postgres and Redis are not part of this chart, and sign-in is OIDC-only.

Image ghcr.io/patchmon/patchmon-server
Versions not listed on purpose — read the current pair off patchmon/Chart.yaml, see Chart index
Workloads one Deployment (1 replica), optional guacd sidecar in the same pod
Storage none — the image is stateless; /tmp is an emptyDir
Routing two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled); ingress.domain survives both gates — see Routing: Ingress or Gateway API
External dependencies Postgres, Redis, an OIDC provider
Secrets 4 × OnePasswordItem
Install-test ci/patchmon/ (fixtures with postgres, redis and a dex OIDC provider)

Prerequisites

  • 1Password operator in the cluster, plus four items — see the fields below.
  • Postgres and Redis reachable before the first start. The server waits for the database and runs migrations at boot.
  • An OIDC provider. There is no local account to fall back on — see Sign-in is OIDC-only.
  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for ingress.domain pointing at the ingress controller.
  • The hostname decided up front. A wrong ingress.domain keeps the pod out of Ready forever — see below.

Installation

The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.

helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update

helm install patchmon jlab/patchmon \
  --namespace patchmon --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml:

secrets:
  postgresSecretRef: op://vault/patchmon-postgres
  redisSecretRef: op://vault/patchmon-redis
  patchmonSecretRef: op://vault/patchmon
  oidcSecretRef: op://vault/patchmon-oidc

ingress:
  domain: patchmon.example.com
  clusterIssuer: letsencrypt

The same installation in Gateway mode instead — the Gateway, its class and its certificate belong to the cluster, not to this release:

ingress:
  enabled: false
  domain: <the public hostname>   # the HTTPRoute's hostname, see below
httpRoute:
  enabled: true
  parentRefs:
    - name: public
      namespace: gateway
      sectionName: https

This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; ingress.clusterIssuer is not used. ingress.domain is still required — it builds CORS_ORIGIN and the probes' Host header. See Routing: Ingress or Gateway API.

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set. Read it — it is the per-release version of this page.

What the 1Password items must contain

The chart renders OnePasswordItem CRs that carry an item path only; the operator turns each into a Kubernetes Secret whose keys are the field names of the item. Which fields each item needs is decided in the Deployment. A missing field does not fail the install and does not even stop the container: the variable arrives empty and the server misbehaves later.

Values key Kubernetes Secret Fields
secrets.postgresSecretRef <release>-patchmon-postgres-secret database-url — e.g. postgresql://user:password@host:5432/patchmon
secrets.redisSecretRef <release>-patchmon-redis-secret host, port, username, password, db
secrets.patchmonSecretRef <release>-patchmon-patchmon-secret jwt-secret, encryption-key (AI_ENCRYPTION_KEY), session-secret
secrets.oidcSecretRef <release>-patchmon-oidc-secret issuer-url, client-id, client-secret, redirect-uri, requested-scopes, button-text, user-group-name, admin-group-name

Watch them materialize:

kubectl -n <namespace> get onepassworditem

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders the Deployment with replicas: 0. Takes precedence over patchmon.server.replicas. Secrets, Service and Ingress stay; the agents on your hosts keep retrying, so nothing is lost.
global object {} Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it.

secrets

Key Type Default Description
secrets.postgresSecretRef string null Required. op:// path of the Postgres item.
secrets.redisSecretRef string null Required. op:// path of the Redis item.
secrets.patchmonSecretRef string null Required. op:// path of the app-secrets item.
secrets.oidcSecretRef string null Required. op:// path of the OIDC item — the chart hardcodes OIDC_ENABLED=true, so this one is not optional.

patchmon.server

Key Type Default Description
patchmon.server.replicas int 1 Replica count; 0 is allowed. Raising it does not scale the server out — see One replica only.
patchmon.server.securityContext.pod object non-root 65532/65532, seccompProfile: RuntimeDefault Passed verbatim into the pod spec. The seccomp profile sits here on purpose: from the pod context it covers every container — the server and the guacd sidecar alike — including any init container added later.
patchmon.server.securityContext.container object readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root The image is a hardened Alpine build running as uid 65532; only /tmp (emptyDir) is writable.
patchmon.server.livenessProbe object GET /health:3000 with Host: {{ .Values.ingress.domain }}, period 10s, timeout 5s, 3 failures The Host header is load-bearing — see below. Probes are rendered through tpl, so the header follows ingress.domain.
patchmon.server.readinessProbe object same, period 10s, timeout 5s, 3 failures
patchmon.server.startupProbe object same, period 10s, 60 failures (= 600s) Gates liveness and readiness. The first 2.x boot rebuilds database indexes, which takes minutes on slow storage.
patchmon.server.env list [] Extra environment variables appended to the chart's own. Rendered through tpl; value, secretKeyRef, configMapKeyRef. Additive, not overriding — see Environment variables.
patchmon.server.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
patchmon.server.resources.requests object cpu: 0.1, memory: 256Mi, ephemeral-storage: 10Mi
patchmon.server.resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.

patchmon.guacd

Key Type Default Description
patchmon.guacd.enabled bool false Runs the Apache Guacamole daemon as a sidecar and points the server at it via GUACD_ADDRESS=127.0.0.1:4822. In-browser RDP for Windows hosts, which upstream still labels beta.
patchmon.guacd.image string docker.io/guacamole/guacd:1.6.0 Full image reference including the tag (not a repository/tag pair). An independent upstream, so it does not follow appVersion; reviewed on every app-update round.
patchmon.guacd.securityContext.container object readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root 1000/1000 No pod key exists here — a sidecar shares the server's pod context.
patchmon.guacd.livenessProbe object TCP 4822, period 10s, timeout 5s, 3 failures
patchmon.guacd.readinessProbe object TCP 4822, period 10s, timeout 5s, 3 failures
patchmon.guacd.startupProbe object TCP 4822, period 5s, 30 failures (= 150s)
patchmon.guacd.env list [] Extra environment variables for the sidecar (e.g. GUACD_LOG_LEVEL); the env block is only rendered when the list is non-empty.
patchmon.guacd.resources.limitFactor number 3
patchmon.guacd.resources.requests object cpu: 0.05, memory: 64Mi, ephemeral-storage: 10Mi
patchmon.guacd.resources.limits object unset

ingress

Key Type Default Description
ingress.enabled bool true false omits the Ingress object and frees clusterIssuer — but not domain. Mutually exclusive with httpRoute.enabled.
ingress.domain string null Required, always — also with ingress.enabled: false. It is the Ingress host, the server's CORS_ORIGIN (https://<domain>), and the Host header of all three probes.
ingress.clusterIssuer string null Required while the Ingress is enabled. cert-manager ClusterIssuer.
ingress.className string nginx Ingress class.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode — but nothing this chart needs lives here: the server's WebSocket upgrade works on a plain route and TRUST_PROXY is an environment variable.

The Ingress carries a single / prefix path: the 2.x server serves the frontend and /api/* from one process, WebSockets included. TLS is stored in tls-<release>-patchmon-ingress.

httpRoute

The Gateway-API alternative to the Ingress, off by default. Added with group 1 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).

Key Type Default Description
httpRoute.enabled bool false Renders <release>-patchmon-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values.
httpRoute.parentRefs list [] The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases.
httpRoute.hostnames list [] Empty means [ingress.domain], so the hostname has a single source of truth. Set it explicitly only to publish under a different or an additional name — which is how a migration off the Ingress runs.

What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-patchmon-service on port 80. One path covers everything, for the same reason the Ingress needs only one.

networkPolicy

Inbound 3000 only. Outbound DNS, TCP 5432 (Postgres), 6379 (Redis) and 443 (the OIDC provider).

Value Type Default Meaning
networkPolicy.enabled bool true Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured.
networkPolicy.allowedNamespaces list [] Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it.
networkPolicy.probeSources list [] CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready.
networkPolicy.ingressController object kube-system / app.kubernetes.io/name: rke2-traefik The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed.
networkPolicy.egress.extraPorts list [] Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off.

Operating notes

Routing: Ingress or Gateway API

Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.

Rule Why it is a hard failure and not a default
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths two data paths onto one Service under one hostname is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname (httpRoute.hostnames set explicitly while the Ingress keeps ingress.domain), never in parallel.
httpRoute.parentRefs must not be empty — the render aborts a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer.
the mode is a value, never auto-detected .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing.

What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, which live in cluster-config. ingress.clusterIssuer is unused and unnecessary in this mode.

ingress.domain is required in this mode too, and not because of the route. It builds CORS_ORIGIN (with its own required()) and the explicit Host header all three probes send. The consequence is the one this page already describes for the Ingress and it is unchanged by a Gateway in front: the 2.x server answers 403 to any request whose Host is neither loopback nor a CORS_ORIGIN host, so a wrong value keeps the pod out of Ready forever. If you set httpRoute.hostnames to a name ingress.domain does not carry, the server rejects that name — set both together. TRUST_PROXY stays right either way; a Gateway is a reverse proxy too.

Whether the route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:

kubectl -n <namespace> get httproute <release>-patchmon-httproute -o yaml

Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.

A wrong ingress.domain keeps the pod out of Ready forever

The 2.x server answers 403 to any request whose Host header is neither loopback nor a CORS_ORIGIN host, and CORS_ORIGIN is built from ingress.domain. Kubelet probes address the pod IP, so they must send the public hostname explicitly — which is why all three probe defaults carry an httpHeaders entry rendered through tpl from ingress.domain.

If that value is not the hostname the server is actually reached under, the probes get 403, the pod never turns Ready, and nothing in the log looks like a configuration error — just repeated 403s. This is the case §12.5 is written from: a probe default carries the probe's context, not just its timings, and where the context is dynamic the fallback helper takes the root context in and rebuilds the header.

The same applies when you address the server yourself:

kubectl -n <namespace> port-forward svc/<release>-patchmon-service 3000:80
curl -H "Host: <ingress.domain>" http://localhost:3000/health

ingress.domain is required even without an Ingress

ingress.enabled: false removes the Ingress object and makes clusterIssuer unnecessary. It does not free ingress.domain: the Deployment reads it for CORS_ORIGIN behind its own required(), and the probe defaults read it through tpl. This is exactly the "a value read outside its obvious template needs its own guard" case from §12.1.

The comment above ingress.enabled in values.yaml and the enabled description in values.schema.json both claim the domain is only read inside the Ingress template. That is stale — the templates and NOTES.txt are right, those two comments are wrong.

Sign-in is OIDC-only, and there is no local account

The chart hardcodes OIDC_ENABLED=true and OIDC_DISABLE_LOCAL_AUTH=true: the login form is gone and there is no built-in admin. If the OIDC item is wrong, nobody gets in — including you.

Users are created on first login (OIDC_AUTO_CREATE_USERS=true) with the readonly role; admin rights follow the group named in the item's admin-group-name field, and roles are synchronized on each login (OIDC_SYNC_ROLES=true). The item's redirect-uri must point back at this installation under https://<ingress.domain> and match what is registered at the identity provider exactly.

The 2.x role vocabulary is superadmin, admin, host_manager, readonly, user. The 1.x viewer role this chart used to default to no longer exists; readonly is its least-privilege equivalent.

One replica only

The server registers itself in the Redis presence registry under its pod hostname, which the chart pins to <release>-patchmon-server — a Deployment pod's default hostname changes on every rollout and would leave stale presence records behind (upstream pins the same name in its compose file). A second replica would register the same identity, so this chart supports exactly one. Raising patchmon.server.replicas does not scale it out.

Give the first start time

The server waits for the database and runs migrations at boot, and the first 2.x start rebuilds the database indexes — minutes on slow storage. The startup probe grants 600s before the kubelet starts killing the container, so a slow first boot is expected, not a hang:

kubectl -n <namespace> logs deploy/<release>-patchmon-deployment -f

Rotating a secret does not restart the pod

Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9). This chart sets neither a checksum/secret-ref annotation nor the operator's operator.1password.io/auto-restart — which, unlike the StatefulSet charts here, it could: the annotation works for Deployments. As it stands, pointing a *SecretRef at a different item rolls the pods; editing a value inside the same item does not:

kubectl -n <namespace> rollout restart deployment/<release>-patchmon-deployment

Rotating jwt-secret or session-secret invalidates existing sessions. Rotating encryption-key without keeping the old value makes data encrypted under it unreadable.

The guacd sidecar

With patchmon.guacd.enabled: true the daemon runs beside the server and is reached at 127.0.0.1:4822; it listens on the pod only and nothing exposes 4822 outside. The image is pinned separately from appVersion and needs its own review at upgrade time — see Renovate, which tracks it through the built-in helm-values manager.

The egress ports are the standard ones, not your configured ones

A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination.

Worse, the chart does not even know the port: the connection details arrive inside 1Password items it never parses. So the rules name the conventional ports, and a dependency listening anywhere else is blocked. Put the real port in networkPolicy.egress.extraPorts.

All three of this chart's dependencies arrive that way, so all three ports are conventions rather than configuration.

The probed port is open inside the cluster by default

networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.

It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.

Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.

The policy is only a boundary where the CNI enforces one

The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.

Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.

Upgrades and backup

  • Back up the database before every app-version bump. Migrations run at boot and are one-way; a helm rollback returns the old image to a database the new one has already rewritten. The chart itself holds no state at all, so the database plus the four 1Password items are the backup.
  • Raise patchmon.server.startupProbe.failureThreshold before a large jump, not after the crash loop: a restart lands in the middle of an index rebuild.
  • App major jumps go stepwise: last release of the old major line first, then the newest major (Versioning). The 1.x → 2.x jump is what turned this chart from two Deployments (frontend + backend) into the single one documented here.
  • Review the guacd pin in the same round.

CI

ci/patchmon/ holds test-values.yaml (op:// refs against the stub CRD, patchmon.ci.local) and fixtures.yaml (throwaway Postgres and Redis, a dex OIDC provider with a static client, and the four dummy secrets). Because the 2.x server is a single stateless container, no PV, StorageClass or service-alias fixtures are needed and the chart takes the default install timeout. The install-test runs on every PR that touches the chart — see CI/CD.

The install-test stays in Ingress mode; the route is held at render level. The install-test installs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. cyberchef is the chart that pays that price and installs in Gateway mode against the real Gateway-API CRDs (ci/gateway-api-crds.sh, pinned, applied --server-side), which is where a malformed parentRefs or backendRefs is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead ci/httproute/patchmon.yaml is a render-only overlay that switches this chart into Gateway mode for ci/run-httproute-match-test.sh, which asserts that every backendRefs entry names a Service and a port the same render creates and that parentRefs is not empty. A chart that has an overlay and renders no route fails that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI. See CI/CD.

See also