helm

Product Console Helm Chart

Chart Contract

A Helm chart for deploying Product Console - Lerian Studio’s web interface for managing Midaz ledger.

Prerequisites

Installing the Chart

To install the chart with the release name product-console:

helm install product-console oci://registry-1.docker.io/lerianstudio/product-console-helm --version <version> -n product-console --create-namespace

This chart pins its own namespace. namespaceOverride ships as product-console, and every template writes that into metadata.namespace, so resources land there whatever -n says. --create-namespace only creates the release namespace, so installing with -n <anything else> fails on a fresh cluster with namespaces "product-console" not found. Either install into product-console as printed above, or set namespaceOverride to the namespace you passed to -n. The bundled MongoDB does not inherit namespaceOverride: it lands in global.namespaceOverride when that is set, and in the namespace you passed to -n otherwise. Keeping the two the same is what lets the console read the bundled database’s password by itself, since a Secret cannot be read across namespaces (see MongoDB and readiness).

Configuration

See values.yaml for the full list of configuration options.

Quick Start

Copy values-template.yaml and customize it for your deployment:

cp values-template.yaml my-values.yaml
# Edit my-values.yaml with your configuration
helm install product-console oci://registry-1.docker.io/lerianstudio/product-console-helm --version <version> -f my-values.yaml -n product-console --create-namespace

Key Configuration Options

Every configmap.<KEY> and its shipped default are declared in templates/configmap.yaml (values.yaml’s configmap: map is intentionally empty — set a key there only to override its shipped default).

Parameter Description Default
replicaCount Number of replicas 1
image.repository Container image repository lerianstudio/product-console
image.tag Container image tag Chart appVersion
ingress.enabled Enable ingress false
configmap.NODE_ENV Node environment production
configmap.MIDAZ_CONSOLE_PORT Console port 8081
configmap.MIDAZ_BASE_PATH Midaz API base path http://midaz-ledger.midaz.svc.cluster.local:3002/v1
configmap.NEXTAUTH_URL Public URL NextAuth uses for OAuth callbacks ingress.hosts[0].host (as https://<host>) when ingress is enabled with a host, else http://localhost:8081
configmap.TRUSTED_PROXIES Comma list of CIDRs the console trusts as its own hops, see Client IP resolution unset (no client IP is resolved)
configmap.PLUGIN_AUTH_PUBLIC_BASE_PATH Browser-facing Access Manager address used for the SSO redirect, see Keys with no default unset (falls back to the cluster-internal PLUGIN_AUTH_BASE_PATH)
configmap.MIDAZ_V2_BASE_PATH Address of the midaz ledger’s /v2 contract, where fees live, see Keys with no default unset (the fees screens have no address)
configmap.MFA_ENABLED Tells the console the Access Manager may answer a password with an MFA challenge, see Keys with no default unset (the image’s own default)
configmap.FLOWKER_BASE_PATH / configmap.LENDER_BASE_PATH / configmap.MATCHER_BASE_PATH / configmap.TRACER_BASE_PATH Optional sibling services, see Keys with no default unset (feature addressed nowhere)
global.scheduling.spread Pod spreading preset (enabled / hostname / zone / maxSkew / minDomains / nodeTaintsPolicy), see Pod Spreading {enabled: true, hostname: ScheduleAnyway, zone: "", maxSkew: 1, minDomains: 0, nodeTaintsPolicy: ""}
spread Field-level override of global.scheduling.spread for the console Deployment {}
topologySpreadConstraints Raw topologySpreadConstraints; non-empty replaces the preset (an entry without labelSelector gets the selector labels) []
readinessProbe.path Readiness endpoint. Defaults to the MongoDB-independent one, see MongoDB and readiness /api/admin/health/alive
secrets.NEXTAUTH_SECRET NextAuth secret (must be supplied for production) ""
secrets.MONGODB_PASS MongoDB password. Leave empty with the bundled MongoDB: the console reads the password this chart keeps for it, see MongoDB and readiness ""
secrets.PLUGIN_AUTH_CLIENT_ID Alternative to configmap.PLUGIN_AUTH_CLIENT_ID when the client_id shouldn’t sit in a ConfigMap; when set, the ConfigMap key is omitted ""

Inter-service defaults (cross-namespace)

Product Console talks to several sibling Lerian services (Midaz ledger, plugin-access-manager, CRM, Reporter). By default these are addressed by the standard in-cluster FQDN (<service>.<namespace>.svc.cluster.local), so the chart works out of the box even when each dependency is deployed in its own namespace — no service mesh or DNS wiring required. Override the corresponding configmap.<KEY> if your environment uses different service/namespace names.

Service configmap keys Default host
Midaz ledger MIDAZ_API_HOST, MIDAZ_TRANSACTION_BASE_HOST (+ *_PORT/*_PATH) midaz-ledger.midaz.svc.cluster.local
plugin-access-manager (auth) PLUGIN_AUTH_HOST (+ PLUGIN_AUTH_PORT/PLUGIN_AUTH_BASE_PATH) plugin-access-manager-auth.plugin-access-manager.svc.cluster.local
plugin-access-manager (identity) PLUGIN_IDENTITY_HOST (+ PLUGIN_IDENTITY_PORT/PLUGIN_IDENTITY_BASE_PATH) plugin-access-manager-identity.plugin-access-manager.svc.cluster.local
CRM plugin CRM_BASE_PATH http://midaz-crm.midaz.svc.cluster.local:4003/v1/
Reporter REPORTER_BASE_PATH http://reporter-manager.reporter.svc.cluster.local:4005/v1

Keys with no default

Most configmap.<KEY> entries ship a default that is right for a standard in-cluster install. Eight do not, because no default is safe to invent for them: an address that depends on where you deployed a sibling release, or an assertion about the deployment. The chart writes nothing for an unset one, so the console keeps whatever its own image does.

Key What it is If it is wrong or missing
TRUSTED_PROXIES CIDRs the console trusts as its own hops Missing: no caller is ever named, so a tenant IP allowlist has nothing to judge. Wrong: see Client IP resolution
PLUGIN_AUTH_PUBLIC_BASE_PATH Browser-facing Access Manager address (absolute, https, ends in /v1) Missing: SSO redirects the browser to the cluster-internal name, which it cannot resolve. Only local dev, where both are localhost, can leave it out
MIDAZ_V2_BASE_PATH The midaz ledger’s /v2 contract, where fees live — same host and port as MIDAZ_BASE_PATH, /v1 swapped for /v2, ending in a slash. See Fees and the ledger’s /v2 contract Missing: every fees screen fails and the health check reports MIDAZ_V2_BASE_PATH is not set
MFA_ENABLED Assertion that the Access Manager in front of this console may answer a correct password with an MFA challenge. It enables MFA for nobody — that is per user, in the Access Manager Missing where MFA is on: the console does not recognise the challenge, and a user with MFA enabled cannot sign in at all
FLOWKER_BASE_PATH Flowker’s address, ends in /v1 Missing: the console has nowhere to send Flowker calls
LENDER_BASE_PATH Lender’s address, ends in /api/v1 — the version prefix is part of it Missing: the console has nowhere to send Lender calls; without /api/v1 every call misses Lender’s routes
MATCHER_BASE_PATH Matcher’s address, bare origin, no /v1 — the console adds it Missing: the console has nowhere to send Matcher calls; with a /v1 suffix every call goes to /v1/v1/...
TRACER_BASE_PATH Tracer’s address, bare origin, no /v1 — the console adds it Missing: the console has nowhere to send Tracer calls; with a /v1 suffix every call goes to /v1/v1/...

Those four sibling services are optional deployments, which is why the chart invents no address for them: a default would turn “this feature is not installed” into a connection error on the page.

Fees and the ledger’s /v2 contract

Fees — fee packages, billing packages, billing runs and the fees statement — used to be a separate plugin. They are not any more: they live inside the midaz ledger, served on its second API contract, /v2. Everything else the console asks the ledger still goes to /v1, so the two addresses coexist and MIDAZ_V2_BASE_PATH is the one the fees screens use.

Set it to the same host and port as MIDAZ_BASE_PATH, with /v1 swapped for /v2:

configmap:
  MIDAZ_BASE_PATH: "http://midaz-ledger.midaz.svc.cluster.local:3002/v1"
  MIDAZ_V2_BASE_PATH: "http://midaz-ledger.midaz.svc.cluster.local:3002/v2/"

Write the trailing slash. The console builds each fees call by resolving a relative path against this address, and that resolution keeps the /v2 segment only when the address ends in a slash. Without it, /v2 is dropped: the call goes to the bare origin, reaches a contract that serves no fees, and nothing in the response says why. The console normalises the value by adding the missing slash, so both forms work today — the form above is the one to write, and the one that survives if that normalisation ever moves.

Set each key in one place only. TRUSTED_PROXIES, MFA_ENABLED and PLUGIN_AUTH_PUBLIC_BASE_PATH were reachable only through extraEnvVars before they were declared here, and still work from there, so no existing install has to change. Supplying one through both configmap and extraEnvVars is refused at render time: both write into the same ConfigMap data map, and the surviving value would be whatever the YAML parser kept.

NEXT_PUBLIC_DEMO_MODE is internal presentation configuration rather than customer surface, so the chart declares no key for it: an install that needs it sets it through extraEnvVars.

Client IP resolution

TRUSTED_PROXIES is the comma list of CIDRs the console treats as its own infrastructure hops while reading X-Forwarded-For, so it can tell which address in that chain is the real caller. The container image ships it empty on purpose, so each environment must name its own hops.

Set it through configmap:

configmap:
  TRUSTED_PROXIES: "198.51.100.0/24" # example: the /24 your edge egresses from

configmap is an explicit allowlist of the keys templates/configmap.yaml declares. TRUSTED_PROXIES was not one of them before, so a value put there was accepted by helm lint, helm template and helm upgrade and then dropped, with nothing in the deploy path to say so; extraEnvVars was the only working channel. It is declared now, and extraEnvVars still works, so an install already delivering it from there needs no change — but set it in one place only, or the render is refused.

Name the addresses that are actually in front of this service, meaning the ingress and load-balancer addresses as they appear as X-Forwarded-For hops. Every proxy APPENDS the address it observed and rewrites nothing, so the hop an ingress controller contributes is its own peer, typically the load balancer in front of it, not the ingress pod. Two ranges that look plausible and are always wrong:

Empty means no caller is ever named. The console resolves no client IP and stamps X-Client-Ip-Resolution: unresolved; reason=trusted-proxies-not-configured on its calls to Lerian backends. That header is always unresolved; reason=<cause> and never the bare word unresolved, so a detection rule written as an exact match on unresolved never fires. At app image 1.12.0, the version this chart deploys, there are SEVEN causes. Five report the trust boundary itself: trusted-proxies-not-configured, forwarded-for-absent, forwarded-for-malformed, all-hops-trusted and unresolved-upstream. Two more report that the resolver never got to run: request-headers-unavailable and client-ip-extraction-failed. A rule enumerating only the first five misses exactly the two cases that mean the console is broken rather than under-configured.

The header does not reach every backend, on purpose. At app image 1.12.0, 12 of the console’s 14 outbound services stamp it. The two that do not are the Slack webhook and the Pix indirect rail, and both omissions are a deliberate privacy boundary the console holds under test: at that tag, forward-client-ip-exclusions.test.ts asserts that neither service sends the header “even when a real client IP IS resolvable”, because the client IP is personal data and the Pix indirect rail terminates at BTG, an external bank. Do not read those two as missing hops to be closed: traffic on them is out of scope for client-IP attribution, not unattributed, and any tooling keyed on the header’s presence should treat them that way.

Entries can be refused. An entry that is not a CIDR is refused, and so is one wider than /8 (IPv4) or /48 (IPv6). A refused entry is named in the console’s own log ONLY when the whole list is refused, which also puts the deployment back in the empty case above. A list that keeps at least one usable range drops its bad entries silently, so 198.51.100.0/24,0.0.0.0/0 trusts the first range, discards the second, and logs nothing naming the discarded entry. The console still logs whenever a request ends up with no caller named, so the warnings are there; the entry that caused them is not.

Do not widen the range to be safe. Trusting a range means discarding its hops and continuing to look left, so a range that covers real callers (a corporate VPN, a peered VPC, an in-cluster client) makes those callers invisible and lets the walk reach a hop the caller wrote. Narrow is correct.

MongoDB and readiness

With the bundled MongoDB, land both in one namespace and there is nothing to configure. configmap.MONGO_HOST defaults to the Service the subchart really creates for the topology shipped here, and MONGODB_PASS is read straight from the Secret this chart keeps for it (key mongodb-root-password), so the console reaches its database and authenticates to it without an operator copying a generated password by hand.

Two bundled configurations have no default and the chart says so. The default host follows the subchart’s own name, which is what its Service is called for a standalone MongoDB and nothing else. Set mongodb.architecture: replicaset and the subchart publishes a headless Service plus one DNS name per replica; set mongodb.service.nameOverride and it renames the Service outright. In both cases the chart refuses to render until you set configmap.MONGO_HOST (or global.datastores.mongo.host), and the refusal prints the host to use, plus the replicaSet= parameter to add to configmap.MONGO_PARAMETERS for a replica set. Refusing is deliberate: the alternative is a console that comes up Ready pointing at a name that resolves in no namespace. Set both, and the rename wins: the refusal names the Service Bitnami really creates for that combination, which is the renamed one.

The password wiring above applies only while the subchart’s namespace equals the console’s, which is what -n product-console gives you as long as global.namespaceOverride is unset. Your own value still wins when you set one, either as secrets.MONGODB_PASS or in your own Secret, which is switched on with useExistingSecret: true and named with existingSecretName (the switch is a boolean; the name lives in the other key).

The one case that needs you: a namespace split. The subchart does not inherit namespaceOverride; it lands in global.namespaceOverride when set and in the -n namespace otherwise, while the console always lives in namespaceOverride. So setting global.namespaceOverride splits them even when -n matches. When those two namespaces differ a Secret cannot be read across them, so the chart leaves MONGODB_PASS alone and the install notes say so. Either move the subchart next to the console by naming the console’s namespace in global.namespaceOverride, which the install notes print as a command to complete with your own values file and flags (with global.namespaceOverride set the subchart reads it instead of -n, so changing -n alone no longer moves it; with it unset the subchart follows -n, which is why a fresh install with -n product-console lands it beside the console), or set both of these to the same value, or every MongoDB-backed page (product enablement, guided tour) fails on an auth error:

mongodb:
  auth:
    rootPassword: "<your password>"
secrets:
  MONGODB_PASS: "<the same password>"

Moving the subchart re-creates the database. With the shipped values it is a standalone Deployment plus a PersistentVolumeClaim named after it, and that claim carries helm.sh/resource-policy: keep, so the upgrade brings the database up in the console’s namespace with an empty volume and leaves the old volume behind for you to delete. Back up whatever that database holds before you run it and restore it afterwards: nothing carries the data across. Whether a split console ever authenticated against that database depends on credentials the operator wired by hand, which the chart cannot see, so do not assume the volume is empty.

Compatibility of the MONGO_HOST default. configmap.MONGO_HOST and global.datastores.mongo.host still win, so an operator who names their host keeps it. One configuration’s WORKING value moves: a deployment that runs its own MongoDB as a Service literally named mongodb in the console’s namespace, left mongodb.enabled: true, and never set MONGO_HOST was resolving the old bare mongodb through the pod’s DNS search path. That deployment now points at the bundled subchart. The remedy is one line, configmap.MONGO_HOST: mongodb, or mongodb.enabled: false if the bundled database was never wanted.

Readiness does not gate on MongoDB by default. readinessProbe.path defaults to /api/admin/health/alive. The MongoDB-aware endpoint, /api/admin/health/readyz, returns 200 only once the app has connected, and app image 1.12.0 connects on the first request that needs the database rather than at startup. Readiness on readyz therefore deadlocks a fresh pod: not Ready, so no traffic, so no connection, so never Ready. Measured on image 1.12.0: readyz stays 503 (readyState=0) for as long as the pod is left alone, and turns 200 within three seconds of the first request to a MongoDB-backed route.

What that default costs you, plainly. Readiness no longer detects the database. A MongoDB outage does not withdraw a pod from the Service: every replica stays Ready and keeps taking traffic for MongoDB-backed routes, which answer 500 rather than being routed away, and an install whose database is unusable still reports STATUS: deployed. Scrape /api/admin/health/readyz and alert on it, because it remains the honest report of whether a pod can serve a MongoDB-backed page; it is only no longer wired to anything that acts on it. Set readinessProbe.path: /api/admin/health/readyz to get that detection back, and only on an app image whose readyz opens the connection it reports on: on image 1.12.0 it never does, and the pod deadlocks as above. Keep liveness on a MongoDB-independent path, or a database outage becomes a crash-loop.

Managed Cloud (global.cloud)

These fields are consumed by lerian-common and let an operator set a value ONCE per environment for every chart that shares it, instead of pinning it per-component. A native configmap.<KEY> (see table above / values.yaml) always wins over the corresponding mask.

global.* field Consumed by Overriding native key(s)
global.auth.enabled / global.auth.host lerian-common.auth.env configmap.PLUGIN_AUTH_ENABLED / configmap.PLUGIN_AUTH_HOST
global.observability.enabled lerian-common.globalValue configmap.ENABLE_TELEMETRY
global.datastores.mongo.{uri,host,port,user,params} lerian-common.datastore.value configmap.MONGODB_URI / MONGO_HOST / MONGO_PORT / MONGODB_USER / MONGO_PARAMETERS

Authorization is OFF unless it is switched on explicitly. The console enforces permissions only when the variable it reads is the word true: PLUGIN_AUTH_ENABLED on the server, NEXT_PUBLIC_PLUGIN_AUTH_ENABLED in the browser. Any other rendered value (false, empty, 1, TRUE, yes) reads as OFF for that half: pages open without a permission check and upstream calls carry no bearer. /api/** requires a session when either PLUGIN_AUTH_ENABLED or OAUTH_ENABLED is the word true, and answers without one only when both are off; OAUTH_ENABLED alone keeps the session requirement but never enables permission checks or bearer forwarding.

How this chart renders the two variables: the server value is configmap.PLUGIN_AUTH_ENABLED when set, else global.auth.enabled, else false. The browser value copies the server value while configmap.NEXT_PUBLIC_PLUGIN_AUTH_ENABLED is unset or empty. A non-empty browser key is rendered as given and is never compared with the server value, so NEXT_PUBLIC_PLUGIN_AUTH_ENABLED: "TRUE" next to an enabled server yields server ON and browser OFF. Leave the browser key unset, or set both to the same word. A staging or production release MUST set global.auth.enabled: true (or the native key) on purpose; the console does not fail closed on a missing switch (product decision, 2026-09-12, recorded in the console repository’s SECURITY.md).

global.cloud: aws also applies automatically (no chart change needed): it sets MONGO_PARAMETERS to the real DocumentDB connection-string shape (tls=true&tlsInsecure=true&directConnection=true&retryWrites=false&...) whenever no more specific override (native key or global.datastores.mongo.params) is set. gcp/azure have no Mongo preset today.

Pod Spreading (global.scheduling.spread)

The console Deployment renders topologySpreadConstraints from the lerian-common spread preset. The default is soft (ScheduleAnyway) across nodes, so replicas are spread across nodes (e.g. spot nodes) whenever possible, but a pod is never left Pending because of it.

Zone spreading is off by default (zone: ""). When scoring a soft spread, the scheduler skips every node that lacks the topology.kubernetes.io/zone label, so on clusters without zone labels (bare-metal, k3s) a zone constraint silently cancels the node spread. Turn it on (zone: ScheduleAnyway) where every node carries zone labels (EKS, GKE, AKS).

global:
  scheduling:
    spread:
      enabled: true            # master switch
      hostname: ScheduleAnyway # kubernetes.io/hostname: ScheduleAnyway | DoNotSchedule | "" (off)
      zone: ""                 # topology.kubernetes.io/zone: same values (off by default)
      maxSkew: 1               # integer >= 1
      minDomains: 0            # DoNotSchedule constraints only; 0 = off
      nodeTaintsPolicy: ""     # Honor | Ignore | "" (Kubernetes default Ignore)
spread: { hostname: DoNotSchedule }  # field-level override (top-level key)
topologySpreadConstraints: []        # raw list; non-empty replaces the preset

On EKS + Karpenter use a hard node spread:

global:
  scheduling:
    spread:
      hostname: DoNotSchedule
      zone: ScheduleAnyway
      maxSkew: 1
      minDomains: 2
      nodeTaintsPolicy: Honor

minDomains: 2 is required with DoNotSchedule. Skew is measured only against nodes that already exist, so on a single eligible node all replicas would otherwise share it. With fewer than 2 nodes the extra replica stays Pending until Karpenter launches another node. nodeTaintsPolicy: Honor stops tainted nodes (for example a node being drained) from counting as domains.

Precedence: topologySpreadConstraints (non-empty, wins entirely) > spread.<field> > global.scheduling.spread.<field> > off. Each preset constraint selects only this Deployment’s own pods (labelSelector = its spec.selector.matchLabels) of the same ReplicaSet (matchLabelKeys: [pod-template-hash]), so a rolling update is never blocked by the old ReplicaSet’s pods, even with DoNotSchedule. Requires Kubernetes >= 1.27 with the MatchLabelKeysInPodTopologySpread feature gate enabled (beta, on by default). Use DoNotSchedule only when the cluster can always provide enough distinct nodes/zones for the replica count. Jobs are not spread.

Upgrade note: the preset is on by default, so upgrading to the chart version that introduces it adds topologySpreadConstraints to the pod template and triggers one rolling restart of the Deployment. No values change is required. To keep the previous pod template exactly, set global.scheduling.spread.enabled: false.

Uninstalling the Chart

helm uninstall product-console -n product-console

Uninstall keeps the data. helm uninstall leaves the bundled MongoDB’s volume (PVC <release>-mongodb) and the Secret holding its root password (<release>-mongodb), so a reinstall under the same release name opens the same data with the same password. This chart, not the MongoDB subchart, owns that Secret. Deleting the data is a separate, manual step, in the namespace the bundled MongoDB runs in, once the uninstall has succeeded:

kubectl delete pvc product-console-mongodb -n product-console
kubectl delete secret product-console-mongodb -n product-console

Delete both. Deleting only the Secret resets nothing: a reinstall generates a new password that the kept data never learned, so the console cannot log in.