Skip to content

Commit b6f00c1

Browse files
jentingclaude
andcommitted
Add liveness probe, README, and harden Dockerfile
- Serve a /healthz liveness probe on a configurable -health-addr (default :8081). - Add a README documenting behaviour, flags, the probe, and the deployment requirements (shared process namespace, root, RBAC). - Pin the runtime image to alpine:3.21 instead of :latest, EXPOSE 8081, and document why the process runs as root. - Add a unit test for the health handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6dec9f6 commit b6f00c1

4 files changed

Lines changed: 161 additions & 3 deletions

File tree

Dockerfile

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ COPY . .
1414
# Build the application
1515
RUN CGO_ENABLED=0 GOOS=linux go build -o vault-operator-helper .
1616

17-
# Use a minimal runtime image
18-
FROM alpine:latest
17+
# Use a minimal runtime image. Pin to a specific minor version instead of
18+
# :latest for reproducible, supply-chain-friendly builds.
19+
FROM alpine:3.21
1920

20-
# Install required utilities
21+
# Install required utilities (pgrep/kill live in procps)
2122
RUN apk --no-cache add procps
2223

2324
# Set working directory
@@ -26,5 +27,13 @@ WORKDIR /
2627
# Copy the compiled binary from the builder stage
2728
COPY --from=builder /app/vault-operator-helper .
2829

30+
# Health/readiness probe server (see -health-addr).
31+
EXPOSE 8081
32+
33+
# NOTE: the process is intentionally left running as root. It signals the main
34+
# container's process across a shared process namespace, which requires matching
35+
# privileges; running as an unprivileged user would break the restart on most
36+
# setups. See README.md for deployment details.
37+
2938
# Run the application
3039
CMD ["/vault-operator-helper"]

README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# vault-operator-helper
2+
3+
A small sidecar that keeps a ConfigMap in sync with the set of namespaces
4+
matching a label selector, and restarts a co-located "main" container whenever
5+
that set changes.
6+
7+
It is intended to run alongside a container (such as Vault) that reads a
8+
comma-separated list of namespaces from a ConfigMap (for example via a
9+
`WATCH_NAMESPACE` key) and only picks up changes on restart.
10+
11+
## How it works
12+
13+
1. On startup it builds a Kubernetes client (in-cluster config, falling back to
14+
a local kubeconfig).
15+
2. It starts a shared informer that watches **namespaces** matching
16+
`--label-selector`.
17+
3. Whenever a matching namespace is added, updated, or deleted, it recomputes
18+
the sorted namespace list and writes it to the ConfigMap under
19+
`--watch-key`.
20+
4. If (and only if) the value actually changed, it sends `SIGTERM` to the main
21+
container's process so it reloads the new configuration.
22+
23+
The namespace list is read from the informer's in-memory cache (no extra API
24+
calls per event) and sorted, so the ConfigMap value is stable and does not
25+
flip — and trigger needless restarts — due to cache iteration order.
26+
27+
## Flags
28+
29+
| Flag | Default | Description |
30+
| --- | --- | --- |
31+
| `-configmap-name` | `watch-namespace-config` | Name of the ConfigMap to update |
32+
| `-configmap-namespace` | `vault` | Namespace of the ConfigMap |
33+
| `-label-selector` | `foo=bar` | Label selector for namespaces to watch |
34+
| `-watch-key` | `WATCH_NAMESPACE` | Key in the ConfigMap holding the namespace list |
35+
| `-main-container` | `main-container` | Process name of the main container to restart |
36+
| `-health-addr` | `:8081` | Address for the liveness probe server |
37+
38+
## Health probe
39+
40+
The process serves a liveness endpoint on `--health-addr`:
41+
42+
- `GET /healthz` — returns `200` once the process is running.
43+
44+
Example probe configuration:
45+
46+
```yaml
47+
livenessProbe:
48+
httpGet:
49+
path: /healthz
50+
port: 8081
51+
```
52+
53+
## Deployment notes
54+
55+
### Shared process namespace
56+
57+
The restart works by signalling the main container's process directly, so the
58+
two containers must share a process namespace. Set this on the pod spec:
59+
60+
```yaml
61+
spec:
62+
shareProcessNamespace: true
63+
```
64+
65+
`-main-container` must match a string in the target process's command line.
66+
67+
### Running as root
68+
69+
The helper signals a process that belongs to another container. Across a shared
70+
process namespace this generally requires matching privileges, so the image
71+
runs as root by design. Switching to an unprivileged user will break the
72+
restart on most setups.
73+
74+
### RBAC
75+
76+
The helper needs to list namespaces and to get/create/update the ConfigMap.
77+
A minimal example:
78+
79+
```yaml
80+
apiVersion: rbac.authorization.k8s.io/v1
81+
kind: ClusterRole
82+
metadata:
83+
name: vault-operator-helper
84+
rules:
85+
- apiGroups: [""]
86+
resources: ["namespaces"]
87+
verbs: ["get", "list", "watch"]
88+
---
89+
apiVersion: rbac.authorization.k8s.io/v1
90+
kind: Role
91+
metadata:
92+
name: vault-operator-helper
93+
namespace: vault
94+
rules:
95+
- apiGroups: [""]
96+
resources: ["configmaps"]
97+
verbs: ["get", "create", "update"]
98+
```
99+
100+
Bind both to the ServiceAccount used by the pod.
101+
102+
## Image
103+
104+
Images are published to `ghcr.io/hsiaoairplane/vault-operator-helper`.
105+
106+
## Development
107+
108+
```sh
109+
make fmt # gofmt
110+
make vet # go vet
111+
make test # go test
112+
make build # build the binary (-race)
113+
```

main.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"flag"
66
"fmt"
77
"log"
8+
"net/http"
89
"os"
910
"os/exec"
1011
"os/signal"
@@ -33,6 +34,7 @@ var (
3334
labelSelector string
3435
watchKey string
3536
mainContainerName string
37+
healthAddr string
3638

3739
clientset kubernetes.Interface
3840
namespaceLister corelisters.NamespaceLister
@@ -50,6 +52,7 @@ func init() {
5052
flag.StringVar(&labelSelector, "label-selector", "foo=bar", "Label selector for namespaces to watch")
5153
flag.StringVar(&watchKey, "watch-key", "WATCH_NAMESPACE", "Key in the ConfigMap to store namespace list")
5254
flag.StringVar(&mainContainerName, "main-container", "main-container", "Name of the main container to restart")
55+
flag.StringVar(&healthAddr, "health-addr", ":8081", "Address for the liveness (/healthz) and readiness (/readyz) probe server")
5356
}
5457

5558
func main() {
@@ -70,6 +73,9 @@ func main() {
7073
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
7174
defer stop()
7275

76+
// Serve the liveness probe for the duration of the process.
77+
startHealthServer()
78+
7379
// Start the namespace watcher and wait for its cache to sync.
7480
if err := watchNamespaces(ctx); err != nil {
7581
log.Fatalf("Error starting namespace watcher: %v", err)
@@ -110,6 +116,26 @@ func getKubeConfig() (*rest.Config, error) {
110116
return config, nil
111117
}
112118

119+
// healthHandler returns 200 once the process is running. It is used for the
120+
// liveness probe.
121+
func healthHandler(w http.ResponseWriter, _ *http.Request) {
122+
w.WriteHeader(http.StatusOK)
123+
_, _ = w.Write([]byte("ok"))
124+
}
125+
126+
// startHealthServer runs an HTTP server exposing the /healthz liveness probe.
127+
func startHealthServer() {
128+
mux := http.NewServeMux()
129+
mux.HandleFunc("/healthz", healthHandler)
130+
131+
go func() {
132+
log.Printf("Serving health probe on %s", healthAddr)
133+
if err := http.ListenAndServe(healthAddr, mux); err != nil && err != http.ErrServerClosed {
134+
log.Printf("Health server error: %v", err)
135+
}
136+
}()
137+
}
138+
113139
// watchNamespaces starts an informer that monitors namespace changes and keeps
114140
// the ConfigMap in sync. It returns once the informer cache has synced; the
115141
// informer keeps running in the background until ctx is cancelled.

main_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package main
22

33
import (
44
"context"
5+
"net/http"
6+
"net/http/httptest"
57
"testing"
68

79
v1 "k8s.io/api/core/v1"
@@ -109,6 +111,14 @@ func TestUpdateConfigMap_NilData(t *testing.T) {
109111
}
110112
}
111113

114+
func TestHealthHandler(t *testing.T) {
115+
rec := httptest.NewRecorder()
116+
healthHandler(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
117+
if rec.Code != http.StatusOK {
118+
t.Errorf("healthHandler status = %d, want %d", rec.Code, http.StatusOK)
119+
}
120+
}
121+
112122
func TestUpdateConfigMap_NoChangeKeepsValue(t *testing.T) {
113123
cs := setupTest(t,
114124
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a"}},

0 commit comments

Comments
 (0)