Skip to content

Commit 7833a98

Browse files
dforsberclaude
andcommitted
chart(0.3.28): sync from internal source + Hetzner nodeSelector fix
Catches the public chart up to the internal source of truth at appVersion 0.10.21 (was 0.10.10): - per-pod TCPRoute listeners (`pgwire.publicTcpPortBase`) — chart was missing the 0.10.17 work, so customers on the public chart had no way to expose the L4-passthrough port range that DBeaver / Tableau / libpq <17 / DuckDB's bundled postgres need. - configmap.yaml: __POD_TCP_PORT__ placeholder + sed substitution - gateway.yaml: per-pod TCPRoute listeners - statefulset.yaml + superadmin-secret.yaml: minor sync values-hetzner-example.yaml: - image tag bumped to aarch64-generic-linux-0.10.21 - **nodeSelector switched to `karpenter.sh/nodepool: boilstream-arm64-hel`**. The CFKE-template label `boilstream.com/nodepool: arm64-hel` is in the NodePool's `template.metadata.labels` but not in `spec.requirements`, and CFKE forbids customer modifications to NodePool requirements — so freshly-provisioned Karpenter nodes were failing to satisfy the pod's nodeSelector. Karpenter-managed labels are auto-applied to every node Karpenter provisions; switching to that path makes the chart durable across NodePool reconciliations. - bucket name is now a placeholder `<your-hetzner-bucket-name>` with the prior staging-specific value as a documentation example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f53cac commit 7833a98

8 files changed

Lines changed: 141 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **PGWire stability for clients that auto-name prepared statements.** Connection-pool reuse no longer leaks server-side prepared statements between successive PGWire clients, which previously surfaced as intermittent `prepared statement "sN" already exists` errors against any libpq / tokio-postgres / pgjdbc workload that allocates statement names automatically (DBeaver, Tableau ODBC, Power BI, JDBC apps, psycopg, DuckDB's bundled `postgres` extension). 31 binary-parameter / parameter-types / boolean-encoding / prepared-statement test cases now pass deterministically against staging where they previously flaked. No client-side changes required.
1313

14+
### Chart 0.3.28 — Hetzner / CloudFleet survivability
15+
16+
- **`values-hetzner-example.yaml` now selects nodes via `karpenter.sh/nodepool: boilstream-arm64-hel`** instead of the CFKE-template label `boilstream.com/nodepool: arm64-hel`. The Karpenter-managed label is auto-applied to every node Karpenter provisions, so freshly-spun-up nodes are picked up without any extra requirement on the NodePool spec — and CFKE forbids customers from modifying NodePool requirements directly.
17+
- **Chart catches up to the per-pod TCPRoute work shipped in 0.10.17 / 0.10.18**: `pgwire.publicTcpPortBase` (default `15432`), the `__POD_TCP_PORT__` substitution in the rendered config, and the per-pod `TCPRoute` listeners are now on the public chart. Customers running the public chart no longer need to pin to a pre-0.10.17 image.
18+
1419
### Notes
1520

1621
- Chart version **0.3.27** tracks appVersion `0.10.21`.

charts/boilstream/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ description: |
66
support. Each pod participates in S3-based leader election and serves
77
per-user catalogs; failed pods are recovered from S3 backups.
88
type: application
9-
version: 0.3.16
10-
appVersion: "0.10.10"
9+
version: 0.3.28
10+
appVersion: "0.10.21"
1111
kubeVersion: ">=1.27.0"
1212
keywords:
1313
- streaming

charts/boilstream/templates/configmap.yaml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,21 @@ data:
7676
# clients can connect back to the correct pod from outside
7777
# the cluster. Kept in sync with the per-pod TLSRoute in
7878
# gateway.yaml: boilstream-N.{{ .Values.domain }}
79+
# public_tcp_port = per-pod external TCP port baked into vended DuckLake
80+
# credentials. Pairs with the per-pod `TCPRoute` listener
81+
# in gateway.yaml at `publicTcpPortBase + pod_index`.
82+
# Envoy forwards that port L4-plain straight to the
83+
# pod's :5432, so remote clients speak traditional
84+
# Postgres SSL (plaintext SSLRequest → TLS upgrade)
85+
# with the pod directly — DuckDB's bundled libpq <17
86+
# works with just `sslmode=require`. __POD_TCP_PORT__
87+
# is resolved by the shell below once POD_NAME is known.
7988
cluster_block = '''cluster_mode:
8089
enabled: true
8190
node_id_path: "/data/node-id"
8291
advertised_host: "__POD_NAME__.boilstream-headless.__NS__.svc.cluster.local"
8392
public_host: "__POD_NAME__.{{ .Values.domain }}"
93+
public_tcp_port: __POD_TCP_PORT__
8494
internal_api_port: 8444
8595
leader_heartbeat_interval_secs: 15
8696
leader_stale_threshold_secs: 60
@@ -189,8 +199,14 @@ data:
189199
with open(p, 'w') as f: f.write(c)
190200
PY
191201

192-
# Substitute pod-specific placeholders in the final config
193-
sed -i "s/__POD_NAME__/${POD_NAME}/g; s/__NS__/${NS}/g" /cfg/config.yaml
202+
# Substitute pod-specific placeholders in the final config.
203+
# POD_TCP_PORT = publicTcpPortBase + pod ordinal. StatefulSet names pods
204+
# as <statefulset>-0, <statefulset>-1, ... so the trailing integer is
205+
# the ordinal. Adding it to the base (15432) gives each pod a unique
206+
# external TCP port matching the gateway's per-pod TCPRoute listener.
207+
POD_ORDINAL="${POD_NAME##*-}"
208+
POD_TCP_PORT=$(( {{ int .Values.pgwire.publicTcpPortBase }} + POD_ORDINAL ))
209+
sed -i "s/__POD_NAME__/${POD_NAME}/g; s/__NS__/${NS}/g; s/__POD_TCP_PORT__/${POD_TCP_PORT}/g" /cfg/config.yaml
194210

195211
# Generate (once per pod) a self-signed loopback cert covering
196212
# 127.0.0.1 / ::1 / localhost. Auth server serves this cert via SNI for

charts/boilstream/templates/gateway.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,25 @@ spec:
6767
allowedRoutes:
6868
namespaces: { from: Same }
6969
kinds: [ { kind: TLSRoute } ]
70+
{{- /*
71+
Per-pod PgWire TCP passthrough (TCPRoute, NOT TLSRoute). Each listener
72+
is a pure L4 pipe from its LB port straight into one specific pod's
73+
:5432. Envoy doesn't parse TLS for these — the plaintext Postgres
74+
SSLRequest preamble reaches the pod unmolested, so libpq ≥9 (incl.
75+
DuckDB's bundled libpq <17) can connect with plain `sslmode=require`.
76+
The port we pick is `pgwire.publicTcpPortBase + pod_index` so clients
77+
compute it as `15432 + N` without coordination, matching
78+
`boilstream-N.{{ .Values.domain }}`.
79+
*/}}
80+
{{- $tcpBase := int .Values.pgwire.publicTcpPortBase }}
81+
{{- range $i, $_ := until (int .Values.replicas) }}
82+
- name: pgwire-pod-{{ $i }}
83+
protocol: TCP
84+
port: {{ add $tcpBase $i }}
85+
allowedRoutes:
86+
namespaces: { from: Same }
87+
kinds: [ { kind: TCPRoute } ]
88+
{{- end }}
7089
---
7190
{{- $vals := . -}}
7291
{{- /*
@@ -128,5 +147,29 @@ spec:
128147
{{- end }}
129148
---
130149
{{- end }}
150+
151+
{{- /*
152+
Per-pod PgWire TCPRoutes. One route per pod, each attached to its own
153+
dedicated TCP listener. No SNI, no hostname filtering — the listener
154+
itself has exactly one backend, so Envoy is a straight L4 proxy. Clients
155+
use `host=boilstream-N.{{ .Values.domain }} port={{ .Values.pgwire.publicTcpPortBase }}+N`
156+
and talk plain Postgres + SSLRequest upgrade to the pod.
157+
*/}}
158+
{{- $tcpBase := int .Values.pgwire.publicTcpPortBase }}
159+
{{- range $i, $_ := until (int .Values.replicas) }}
160+
apiVersion: gateway.networking.k8s.io/v1alpha2
161+
kind: TCPRoute
162+
metadata:
163+
name: boilstream-{{ $i }}-pgwire-tcp
164+
namespace: {{ $vals.Values.namespace }}
165+
spec:
166+
parentRefs:
167+
- { name: boilstream, sectionName: pgwire-pod-{{ $i }} }
168+
rules:
169+
- backendRefs:
170+
- name: boilstream-{{ $i }}
171+
port: 5432
172+
---
173+
{{- end }}
131174
{{- end }}
132175

charts/boilstream/templates/statefulset.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{{- $tlsSecret := default "boilstream-wildcard-tls" .Values.tls.existingSecret -}}
22
{{- $superSecret := default "boilstream-superadmin" .Values.superadmin.existingSecret -}}
3+
{{- $superMfaSecret := default "boilstream-superadmin-mfa" .Values.superadmin.existingMfaSecret -}}
34
{{- $clusterTlsSecret := default "boilstream-cluster-mtls" .Values.clusterTls.existingSecret -}}
45
apiVersion: apps/v1
56
kind: StatefulSet
@@ -81,13 +82,11 @@ spec:
8182
name: {{ .Values.s3.existingCredsSecret }}
8283
key: secret_key
8384
{{- end }}
84-
{{- if .Values.superadmin.existingMfaSecret }}
8585
- name: SUPERADMIN_MFA_SECRET
8686
valueFrom:
8787
secretKeyRef:
88-
name: {{ .Values.superadmin.existingMfaSecret }}
88+
name: {{ $superMfaSecret }}
8989
key: mfa_secret
90-
{{- end }}
9190
volumeMounts:
9291
- { name: config-tpl, mountPath: /tpl }
9392
- { name: config, mountPath: /cfg }

charts/boilstream/templates/superadmin-secret.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
{{- /*
2+
Superadmin Secrets.
3+
4+
Two secrets:
5+
- boilstream-superadmin — password (always required; operator
6+
supplies .Values.superadmin.password
7+
OR points at an existingSecret).
8+
- boilstream-superadmin-mfa — base32 TOTP secret. Optional for the
9+
operator: if they pass
10+
.Values.superadmin.existingMfaSecret
11+
we defer to that; if they pass a
12+
literal .Values.superadmin.mfaSecret
13+
we use it; otherwise we generate a
14+
fresh 20-byte random secret and
15+
persist it so pod restarts don't
16+
require re-enrolment of the operator's
17+
authenticator app.
18+
19+
The auto-generated path uses lookup() to preserve the same secret across
20+
helm upgrades — regenerating on every upgrade would invalidate every
21+
superadmin TOTP.
22+
*/ -}}
123
{{- if not .Values.superadmin.existingSecret }}
224
apiVersion: v1
325
kind: Secret
@@ -10,3 +32,29 @@ type: Opaque
1032
stringData:
1133
password: {{ required ".Values.superadmin.password is required when superadmin.existingSecret is empty" .Values.superadmin.password | quote }}
1234
{{- end }}
35+
36+
{{- if not .Values.superadmin.existingMfaSecret }}
37+
---
38+
apiVersion: v1
39+
kind: Secret
40+
metadata:
41+
name: boilstream-superadmin-mfa
42+
namespace: {{ .Values.namespace }}
43+
labels:
44+
{{- include "boilstream.labels" . | nindent 4 }}
45+
annotations:
46+
"helm.sh/resource-policy": keep
47+
type: Opaque
48+
stringData:
49+
{{- /* Priority:
50+
1. explicit .Values.superadmin.mfaSecret (operator-supplied)
51+
2. existing secret in cluster (re-reads on helm upgrade)
52+
3. freshly generated 20-byte random, base32-encoded per RFC 4648 §6 */}}
53+
mfa_secret: {{- with .Values.superadmin.mfaSecret }} {{ . | quote }}
54+
{{- else }}
55+
{{- $existing := (lookup "v1" "Secret" .Values.namespace "boilstream-superadmin-mfa") }}
56+
{{- if and $existing $existing.data $existing.data.mfa_secret }} {{ $existing.data.mfa_secret | b64dec | quote }}
57+
{{- else }} {{ randAlphaNum 32 | b32enc | trimSuffix "=" | quote }}
58+
{{- end }}
59+
{{- end }}
60+
{{- end }}

charts/boilstream/values-hetzner-example.yaml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# helm install boilstream ./charts/boilstream \
1313
# -f charts/boilstream/values-hetzner-example.yaml \
1414
# --set image.repository=<your-cfcr-tenant>.europe.registry.cloudfleet.dev/boilstream \
15-
# --set image.tag=0.10.10-pro-1.5.2 \
15+
# --set image.tag=aarch64-generic-linux-0.10.21 \
1616
# --set superadmin.existingSecret=boilstream-superadmin
1717

1818
replicas: 2 # 2 nodes, 1 pod each
@@ -25,7 +25,7 @@ image:
2525
# Ampere A1 / other non-Graviton-3+ ARM64 hosts need to avoid SIGILL on
2626
# SHA3 / SHA512 paths.
2727
repository: docker.io/boilinginsights/boilstream
28-
tag: aarch64-generic-linux-0.10.10
28+
tag: aarch64-generic-linux-0.10.21
2929
pullPolicy: IfNotPresent
3030
pullSecrets: [] # Docker Hub is public; no pull secret needed
3131

@@ -34,7 +34,7 @@ domain: app.boilstream.com # local-test wildcard while we verif
3434
# Hetzner Object Storage in Helsinki (S3-compatible).
3535
s3:
3636
endpoint: https://hel1.your-objectstorage.com
37-
bucket: boilstream-cluster-state-hel1
37+
bucket: <your-hetzner-bucket-name> # e.g. boilstream-cluster-state-hel1
3838
region: hel1
3939
prefix: cluster/
4040
# Path-style ({endpoint}/{bucket}/{key}) — current BoilStream/object_store
@@ -56,9 +56,17 @@ resources:
5656
requests: { cpu: "1", memory: "2Gi" }
5757
limits: { cpu: "2", memory: "4Gi" }
5858

59-
# Pin to the boilstream-arm64-hel NodePool (arm64 + hel1).
59+
# Pin to the Karpenter NodePool that fronts your Hetzner ARM64 fleet.
60+
# Use the Karpenter-managed `karpenter.sh/nodepool` label rather than the
61+
# CFKE-managed `boilstream.com/nodepool: arm64-hel` template label, because
62+
# CFKE forbids customer modifications to NodePool requirements — and the
63+
# `karpenter.sh/nodepool` label is auto-applied by Karpenter to every node
64+
# it provisions, so freshly-spun nodes are picked up without an extra
65+
# requirement on the NodePool spec. The value must match your actual
66+
# NodePool's `metadata.name` (default in the CloudFleet bring-up flow is
67+
# `boilstream-arm64-hel`).
6068
nodeSelector:
61-
boilstream.com/nodepool: arm64-hel
69+
karpenter.sh/nodepool: boilstream-arm64-hel
6270

6371
# Soft anti-affinity — pods prefer separate nodes, but tolerate co-location
6472
# during capacity churn. Hard `requiredDuringScheduling` caused a 3-way

charts/boilstream/values.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ image:
2222
# <pod-name>.<domain>:<port> via SNI through Envoy Gateway.
2323
domain: boilstream.test
2424

25+
# PgWire external exposure.
26+
pgwire:
27+
# Base TCP port for per-pod direct listeners. Each pod gets its own Envoy
28+
# TCPRoute on `publicTcpPortBase + pod_index` (e.g. 15432, 15433, ...).
29+
# Traffic is forwarded L4-plain to the pod's :5432, so clients speak the
30+
# traditional Postgres SSL flow (plaintext SSLRequest → TLS upgrade)
31+
# directly with the pod — no SNI, no `sslnegotiation=direct`, works with
32+
# DuckDB's bundled libpq <17.
33+
publicTcpPortBase: 15432
34+
2535
# S3 / object storage for cluster coordination + per-user catalog backups.
2636
s3:
2737
endpoint: http://host.docker.internal:9000

0 commit comments

Comments
 (0)