Skip to content

Commit f48c894

Browse files
feat: prepare go build for connected chains healthchecks
1 parent bd8d443 commit f48c894

9 files changed

Lines changed: 329 additions & 9 deletions

File tree

.github/workflows/release-bitcoind.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ on:
77
branches: [main]
88
paths: ["images/bitcoind/**"]
99

10-
# Declare default permissions as read only.
11-
permissions: read-all
12-
1310
jobs:
1411
call:
1512
uses: ./.github/workflows/release-image.yml
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Release Connected Chains Healthcheck
2+
on:
3+
push:
4+
branches: [main]
5+
paths: ["images/go_chains_hc/**"]
6+
pull_request:
7+
branches: [main]
8+
paths: ["images/go_chains_hc/**"]
9+
10+
jobs:
11+
call:
12+
uses: ./.github/workflows/release-image.yml
13+
with:
14+
image: ghcr.io/${{ github.repository }}/go_chains_hc
15+
context: images/go_chains_hc
16+
dockerfile: Dockerfile
17+
secrets:
18+
registry-password: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/release-dogecoind.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ on:
77
branches: [main]
88
paths: ["images/dogecoind/**"]
99

10-
# Declare default permissions as read only.
11-
permissions: read-all
12-
1310
jobs:
1411
call:
1512
uses: ./.github/workflows/release-image.yml

.github/workflows/release-rippled.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ on:
77
branches: [main]
88
paths: ["images/rippled/**"]
99

10-
# Declare default permissions as read only.
11-
permissions: read-all
12-
1310
jobs:
1411
call:
1512
uses: ./.github/workflows/release-image.yml

images/go_chains_hc/Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM golang AS builder
2+
3+
WORKDIR /app
4+
COPY go.mod ./
5+
RUN go mod download
6+
COPY *.go ./
7+
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /node-healthcheck
8+
9+
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:4cf9e68a5cbd8c9623480b41d5ed6052f028c44cc29f91b21590613ab8bec824
10+
11+
COPY --from=builder /node-healthcheck /node-healthcheck
12+
13+
EXPOSE 8080
14+
ENTRYPOINT ["/node-healthcheck"]

images/go_chains_hc/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
## Connected Chains Healthcheck
2+
3+
A minimal Go binary/container that exposes a `/readyz` HTTP endpoint for blockchain node readiness probes. Designed to be used alongside any compatible node (Bitcoin, Dogecoin, Litecoin, etc.) in a Kubernetes pod.
4+
5+
### How it works
6+
7+
On each `/readyz` request, the sidecar runs a configurable set of checks against the node's JSON-RPC API. It returns 200 OK when all checks pass, or an error with a message on the first failing check.
8+
9+
### Checks
10+
11+
| Check | RPC Method | Description |
12+
|---|---|---|
13+
| `blockdownload` | `getblockchaininfo` | Passes when `initialblockdownload` is `false`. Default for all chains. |
14+
| `txindex` | `getindexinfo` | Passes when `txindex.synced` is `true`. |
15+
16+
### Environment Variables
17+
18+
| Variable | Required | Default | Description |
19+
|---|---|---|---|
20+
| `NODE_URL` | yes || RPC endpoint, e.g. `http://localhost:8332` |
21+
| `NODE_USER` | no || RPC auth username |
22+
| `NODE_PASS` | no || RPC auth password |
23+
| `CHECKS` | no | `blockdownload` | Comma-separated list of checks to run (blockdownload,txindex) |
24+
25+
### Running Locally
26+
27+
```bash
28+
go build -o node-healthcheck .
29+
NODE_URL=http://localhost:8332 NODE_USER=user NODE_PASS=secret CHECKS=blockdownload,txindex ./node-healthcheck
30+
```
31+
32+
```bash
33+
# Readiness
34+
curl http://localhost:8080/readyz
35+
```
36+
37+
Example Output:
38+
```json
39+
{"time":"2026-03-04T08:43:48.697Z","level":"INFO","msg":"starting","addr":":8080","checks":["blockdownload"],"node":"http://localhost:8332"}
40+
{"time":"2026-03-04T08:44:01.123Z","level":"DEBUG","msg":"rpc response","method":"getblockchaininfo","body":"{\"result\":{\"initialblockdownload\":false,...}}"}
41+
```
42+
43+
### Kubernetes Sidecar Setup
44+
45+
```yaml
46+
containers:
47+
- name: bitcoin
48+
...
49+
- name: healthcheck
50+
image: node-healthcheck:latest
51+
env:
52+
- name: NODE_URL
53+
value: "http://bitcoin:8332"
54+
- name: NODE_USER
55+
value: "admin"
56+
- name: NODE_PASS
57+
valueFrom:
58+
secretKeyRef:
59+
name: node-rpc-secret
60+
key: password
61+
- name: CHECKS
62+
value: blockdownload,txindex
63+
ports:
64+
- containerPort: 8080
65+
66+
readinessProbe:
67+
httpGet:
68+
path: /readyz
69+
port: 8080
70+
initialDelaySeconds: 60
71+
periodSeconds: 30
72+
timeoutSeconds: 20
73+
failureThreshold: 10
74+
```
75+

images/go_chains_hc/checks.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"log/slog"
10+
"net/http"
11+
)
12+
13+
type Check func(ctx context.Context, client *http.Client, cfg Config) error
14+
15+
var registry = map[string]Check{
16+
"blockdownload": checkBlockDownload,
17+
"txindex": checkTxIndex,
18+
}
19+
20+
type rpcRequest struct {
21+
JSONRPC string `json:"jsonrpc"`
22+
ID string `json:"id"`
23+
Method string `json:"method"`
24+
Params []any `json:"params"`
25+
}
26+
27+
func doRPC(ctx context.Context, client *http.Client, cfg Config, method string) (json.RawMessage, error) {
28+
logger := slog.Default()
29+
body, err := json.Marshal(rpcRequest{JSONRPC: "1.0", ID: "hc", Method: method, Params: []any{}})
30+
if err != nil {
31+
return nil, fmt.Errorf("marshal request: %w", err)
32+
}
33+
34+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.NodeURL, bytes.NewReader(body))
35+
if err != nil {
36+
return nil, fmt.Errorf("build request: %w", err)
37+
}
38+
req.Header.Set("Content-Type", "application/json")
39+
if cfg.NodeUser != "" {
40+
req.SetBasicAuth(cfg.NodeUser, cfg.NodePass)
41+
}
42+
43+
resp, err := client.Do(req)
44+
if err != nil {
45+
return nil, fmt.Errorf("node unreachable: %w", err)
46+
}
47+
defer resp.Body.Close()
48+
49+
if resp.StatusCode != http.StatusOK {
50+
return nil, fmt.Errorf("node returned HTTP %d", resp.StatusCode)
51+
}
52+
53+
data, err := io.ReadAll(resp.Body)
54+
55+
if err != nil {
56+
return nil, fmt.Errorf("read response: %w", err)
57+
}
58+
59+
logger.Debug("rpc response", "method", method, "body", string(data))
60+
61+
var envelope struct {
62+
Result json.RawMessage `json:"result"`
63+
Error any `json:"error"`
64+
}
65+
66+
if err := json.Unmarshal(data, &envelope); err != nil {
67+
return nil, fmt.Errorf("invalid JSON from node: %w", err)
68+
}
69+
70+
if envelope.Error != nil {
71+
return nil, fmt.Errorf("node RPC error: %v", envelope.Error)
72+
}
73+
74+
return envelope.Result, nil
75+
}
76+
77+
func checkBlockDownload(ctx context.Context, client *http.Client, cfg Config) error {
78+
result, err := doRPC(ctx, client, cfg, "getblockchaininfo")
79+
if err != nil {
80+
return err
81+
}
82+
83+
var info struct {
84+
InitialBlockDownload bool `json:"initialblockdownload"`
85+
}
86+
87+
if err := json.Unmarshal(result, &info); err != nil {
88+
return fmt.Errorf("parse getblockchaininfo: %w", err)
89+
}
90+
91+
if info.InitialBlockDownload {
92+
return fmt.Errorf("node is still syncing (initialblockdownload=true)")
93+
}
94+
95+
return nil
96+
}
97+
98+
func checkTxIndex(ctx context.Context, client *http.Client, cfg Config) error {
99+
result, err := doRPC(ctx, client, cfg, "getindexinfo")
100+
if err != nil {
101+
return err
102+
}
103+
104+
var info struct {
105+
TxIndex struct {
106+
Synced bool `json:"synced"`
107+
} `json:"txindex"`
108+
}
109+
110+
if err := json.Unmarshal(result, &info); err != nil {
111+
return fmt.Errorf("parse getindexinfo: %w", err)
112+
}
113+
114+
if !info.TxIndex.Synced {
115+
return fmt.Errorf("txindex is not synced")
116+
}
117+
118+
return nil
119+
}

images/go_chains_hc/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module go_hc
2+
3+
go 1.25.5

images/go_chains_hc/main.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"time"
11+
)
12+
13+
type Config struct {
14+
NodeURL string
15+
NodeUser string
16+
NodePass string
17+
Checks []string
18+
Addr string
19+
}
20+
21+
func configFromEnv() (Config, error) {
22+
nodeURL := os.Getenv("NODE_URL")
23+
if nodeURL == "" {
24+
return Config{}, fmt.Errorf("NODE_URL is required")
25+
}
26+
27+
checks := []string{"blockdownload"}
28+
if raw := os.Getenv("CHECKS"); raw != "" {
29+
checks = strings.Split(raw, ",")
30+
}
31+
32+
for _, name := range checks {
33+
if _, ok := registry[name]; !ok {
34+
return Config{}, fmt.Errorf("unknown check %q (available: %s)", name, availableChecks())
35+
}
36+
}
37+
38+
return Config{
39+
NodeURL: nodeURL,
40+
NodeUser: os.Getenv("NODE_USER"),
41+
NodePass: os.Getenv("NODE_PASS"),
42+
Checks: checks,
43+
Addr: ":8080",
44+
}, nil
45+
}
46+
47+
func availableChecks() string {
48+
names := make([]string, 0, len(registry))
49+
for k := range registry {
50+
names = append(names, k)
51+
}
52+
return strings.Join(names, ", ")
53+
}
54+
55+
func main() {
56+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
57+
slog.SetDefault(logger)
58+
59+
cfg, err := configFromEnv()
60+
if err != nil {
61+
logger.Error("invalid configuration", "error", err)
62+
os.Exit(1)
63+
}
64+
65+
logger.Info("starting", "addr", cfg.Addr, "checks", cfg.Checks, "node", cfg.NodeURL)
66+
67+
client := &http.Client{Timeout: 10 * time.Second}
68+
69+
mux := http.NewServeMux()
70+
mux.HandleFunc("/readyz", handleReadyz(client, cfg, logger))
71+
72+
srv := &http.Server{
73+
Addr: cfg.Addr,
74+
Handler: mux,
75+
ReadTimeout: 15 * time.Second,
76+
WriteTimeout: 15 * time.Second,
77+
}
78+
79+
if err := srv.ListenAndServe(); err != nil {
80+
logger.Error("server error", "error", err)
81+
os.Exit(1)
82+
}
83+
}
84+
85+
func handleReadyz(client *http.Client, cfg Config, logger *slog.Logger) http.HandlerFunc {
86+
return func(w http.ResponseWriter, r *http.Request) {
87+
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
88+
defer cancel()
89+
90+
for _, name := range cfg.Checks {
91+
if err := registry[name](ctx, client, cfg); err != nil {
92+
logger.Warn("check failed", "check", name, "error", err)
93+
http.Error(w, fmt.Sprintf("check %q failed: %v", name, err), http.StatusServiceUnavailable)
94+
return
95+
}
96+
}
97+
98+
w.WriteHeader(http.StatusOK)
99+
}
100+
}

0 commit comments

Comments
 (0)