cmd/rlp-export: admin_exportChain CLI wrapper (Task #118)

Symmetric with cmd/rlp-import. Thin HTTP client that:

  1. Idempotency precheck reads <output-file>.sentinel on the local PVC
     and short-circuits when Status=done + range matches — luxd is never
     contacted in the warm-cron case.
  2. Health-probes ${LuxdRPC}/ext/health (30s cap).
  3. POSTs admin_exportChain via /ext/bc/${chain}/rpc with KMS_AUTH_TOKEN
     bearer auth (same env-var contract as rlp-import).
  4. Maps Status → exit code:
       ok | noop      → 0
       interrupted    → 4 (operator re-runs with from-height=HighestExported+1)
       bad input      → 1
       luxd unreach   → 2
       admin RPC err  → 3

Lives in the cmd/ nested module per the existing rlp-import precedent.
Builds with GOWORK=off; Makefile target `make rlp-export` does this for you.

Tests cover: flag parsing, "latest" resolution, all four idempotency
fall-through branches, happy path + noop + interrupted server responses,
luxd unreachable, admin RPC error, KMS_AUTH_TOKEN bearer header
plumbing. 14 tests, all green.

Symmetric Makefile additions: `make rlp-export`, `make rlp-import`,
`make cmds`, `make test-rlp-export`.
This commit is contained in:
Hanzo AI
2026-06-05 00:29:10 -07:00
parent 6c3ba05cf8
commit ff32c62328
3 changed files with 804 additions and 0 deletions
+23
View File
@@ -27,6 +27,29 @@ build:
@mkdir -p $(BIN_DIR) @mkdir -p $(BIN_DIR)
$(GOBUILD) -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) ./cmd/genesis $(GOBUILD) -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) ./cmd/genesis
# Build the rlp-export CLI (admin_exportChain wrapper).
# Lives in the nested cmd/ module so it builds with GOWORK=off, same as
# its sibling cmd/rlp-import.
.PHONY: rlp-export
rlp-export:
@mkdir -p $(BIN_DIR)
cd cmd/rlp-export && GOWORK=off $(GOBUILD) -ldflags="$(LDFLAGS)" -o ../../$(BIN_DIR)/rlp-export .
# Build the rlp-import CLI (admin_importChain wrapper) — symmetric with rlp-export.
.PHONY: rlp-import
rlp-import:
@mkdir -p $(BIN_DIR)
cd cmd/rlp-import && GOWORK=off $(GOBUILD) -ldflags="$(LDFLAGS)" -o ../../$(BIN_DIR)/rlp-import .
# Build everything in cmd/ (genesis + rlp-export + rlp-import).
.PHONY: cmds
cmds: build rlp-export rlp-import
# Run tests for the rlp-export tool (uses GOWORK=off for the nested module).
.PHONY: test-rlp-export
test-rlp-export:
cd cmd/rlp-export && GOWORK=off $(GOTEST) -v -race .
# Run tests # Run tests
test: test:
$(GOTEST) -v -race ./... $(GOTEST) -v -race ./...
+361
View File
@@ -0,0 +1,361 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Command rlp-export streams a luxd chain segment to an RLP file via the
// admin_exportChain JSON-RPC endpoint, symmetric with cmd/rlp-import.
//
// Decomplection:
//
// admin_exportChain (server-side, ~/work/lux/evm/plugin/evm/admin_api.go)
// is the single source of truth — it owns the block iterator, the gzip
// optional pipe, and the sentinel write. This binary is a thin client that
// builds the JSON-RPC envelope, POSTs it, and translates the response into
// an exit code the operator-side Job controller can read.
//
// Lifecycle:
//
// 1. Idempotency precheck. Read the sentinel locally (mounted via the
// same PVC as luxd) and if it shows Status=done + matching {first,last,
// firstHash,lastHash} for THIS run's range, exit 0 without calling luxd.
// This is the "operator schedules the same export every hour" optimization.
// 2. Health probe. Confirm luxd is reachable on ${LuxdRPC}/ext/health.
// 3. RPC call. POST admin_exportChain to ${LuxdRPC}/ext/bc/${ChainAlias}/rpc
// with {file, first, last}. Respect KMS_AUTH_TOKEN bearer auth same as
// rlp-import.
// 4. Translate the response status into an exit code:
// Status="ok" → exit 0
// Status="noop" → exit 0 (server-side idempotency hit)
// Status="interrupted" → exit 4 (operator should re-run with the
// sentinel's HighestExported as --from-height)
// any error → exit 3
//
// Failure modes (exit codes — K8s reads these via backoffLimit):
//
// - exit 1: bad CLI args, missing required flag.
// - exit 2: luxd unreachable / admin API not enabled.
// - exit 3: admin_exportChain returned a JSON-RPC error.
// - exit 4: admin_exportChain returned Status=interrupted (re-run needed).
//
// Idempotency: if the same (chain, fromHeight, toHeight) was previously
// completed (sentinel.status=="done"), the local precheck short-circuits
// before luxd is even contacted. If the precheck is skipped (e.g. CLI
// runs from a different host than luxd), the SERVER does the same check
// against its own sentinel and returns Status="noop".
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
const (
exitOK = 0
exitBadInput = 1
exitLuxdUnreach = 2
exitAdminRPCError = 3
exitInterrupted = 4
)
// exportHTTPClient is package-level so tests can substitute a stub. The
// timeout caps a single export — for a 1M-block C-Chain (~25min on cold
// pebbledb), 6h is conservative.
var exportHTTPClient = &http.Client{Timeout: 6 * time.Hour}
type config struct {
chainAlias string
outputFile string
fromHeight uint64
toHeight string // "latest" or a uint64
luxdRPC string
}
func main() {
cfg, err := parseFlags(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "rlp-export: %v\n", err)
os.Exit(exitBadInput)
}
// Ctrl-C → cancel the in-flight POST. The server-side admin_exportChain
// checks ctx.Done() at every block boundary so this is honored quickly.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
code, runErr := run(ctx, cfg)
if runErr != nil {
fmt.Fprintf(os.Stderr, "rlp-export: %v\n", runErr)
}
os.Exit(code)
}
// parseFlags decomplects argv parsing from execution so tests can build a
// config struct directly without going through flag.CommandLine.
func parseFlags(args []string) (*config, error) {
fs := flag.NewFlagSet("rlp-export", flag.ContinueOnError)
fs.SetOutput(io.Discard)
alias := fs.String("chain", "", "luxd chain alias (e.g. C, X, hanzo) — required")
out := fs.String("output-file", "", "absolute path on the luxd PVC to write the RLP into — required")
from := fs.Uint64("from-height", 0, "first block height to export (inclusive). Default 0.")
to := fs.String("to-height", "latest", `last block height to export (inclusive). "latest" → current head.`)
rpc := fs.String("luxd-rpc", "http://127.0.0.1:9630", "luxd HTTP base URL")
if err := fs.Parse(args); err != nil {
return nil, err
}
cfg := &config{
chainAlias: strings.TrimSpace(*alias),
outputFile: strings.TrimSpace(*out),
fromHeight: *from,
toHeight: strings.TrimSpace(*to),
luxdRPC: strings.TrimRight(*rpc, "/"),
}
if cfg.chainAlias == "" {
return nil, errors.New("--chain required")
}
if cfg.outputFile == "" {
return nil, errors.New("--output-file required")
}
if cfg.toHeight == "" {
return nil, errors.New("--to-height required (use 'latest' for current head)")
}
return cfg, nil
}
// run performs the four-step lifecycle. Returns (exit code, optional error).
// The exit code is the contract — err is diagnostic context.
func run(ctx context.Context, c *config) (int, error) {
// 1. Idempotency precheck. The sentinel lives at <output-file>.sentinel
// per the server-side contract. If we can read it and it shows a
// matching "done" record for this exact request, skip the RPC.
if code, ok := localIdempotencyShortCircuit(c); ok {
return code, nil
}
// 2. Health probe.
if err := waitForLuxd(ctx, c.luxdRPC, 30*time.Second); err != nil {
return exitLuxdUnreach, fmt.Errorf("luxd unreachable: %w", err)
}
// 3. POST admin_exportChain. Use math.MaxUint64-style sentinel for
// "latest" — the server clamps to the current head.
first := c.fromHeight
last, err := resolveToHeight(c.toHeight)
if err != nil {
return exitBadInput, err
}
result, err := callAdminExportChain(ctx, c.luxdRPC, c.chainAlias, c.outputFile, first, last)
if err != nil {
// Distinguish ctx.Done() from network/RPC failures.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
fmt.Fprintf(os.Stderr, "rlp-export: canceled mid-flight: %v\n", err)
return exitInterrupted, err
}
return exitAdminRPCError, fmt.Errorf("admin_exportChain: %w", err)
}
// 4. Status → exit code.
switch result.Status {
case "ok":
fmt.Printf("rlp-export: ok chain=%s file=%s blocks=%d first=%d last=%d firstHash=%s lastHash=%s\n",
c.chainAlias, c.outputFile, result.BlocksExported, result.First, result.Last,
result.FirstHash, result.LastHash)
return exitOK, nil
case "noop":
fmt.Printf("rlp-export: noop chain=%s file=%s first=%d last=%d (server-side idempotency hit)\n",
c.chainAlias, c.outputFile, result.First, result.Last)
return exitOK, nil
case "interrupted":
fmt.Fprintf(os.Stderr, "rlp-export: interrupted chain=%s file=%s highest=%d (re-run with --from-height=%d)\n",
c.chainAlias, c.outputFile, result.HighestExported, result.HighestExported+1)
return exitInterrupted, nil
default:
return exitAdminRPCError, fmt.Errorf("unknown status %q", result.Status)
}
}
// localIdempotencyShortCircuit reads the sentinel file (when the CLI and
// luxd share the same PVC) and returns (exitOK, true) if it shows a
// completed export matching this run's range. Returns (0, false) for any
// other case so the caller falls through to the RPC path.
//
// This is a soft check — the SERVER is also idempotent. When the CLI runs
// on a different host (no PVC access), this just always falls through.
func localIdempotencyShortCircuit(c *config) (int, bool) {
sentinel := c.outputFile + ".sentinel"
data, err := os.ReadFile(sentinel)
if err != nil {
return 0, false
}
var s struct {
Status string `json:"status"`
First uint64 `json:"first"`
Last uint64 `json:"last"`
HighestExported uint64 `json:"highestExported"`
}
if err := json.Unmarshal(data, &s); err != nil {
return 0, false
}
if s.Status != "done" {
return 0, false
}
if s.First != c.fromHeight {
return 0, false
}
// For --to-height=latest we can't easily know the target last without
// asking luxd. Fall through to the RPC, which will be a server-side
// noop if the heights still match.
if c.toHeight == "latest" {
return 0, false
}
wantLast, err := resolveToHeight(c.toHeight)
if err != nil {
return 0, false
}
if s.Last != wantLast {
return 0, false
}
if _, err := os.Stat(c.outputFile); err != nil {
return 0, false
}
fmt.Printf("rlp-export: noop (local sentinel matches) chain=%s file=%s first=%d last=%d\n",
c.chainAlias, c.outputFile, s.First, s.Last)
return exitOK, true
}
// resolveToHeight parses --to-height. "latest" maps to math.MaxUint64 — the
// server clamps to the current head, so this is the canonical way to say
// "everything you have right now".
func resolveToHeight(s string) (uint64, error) {
if s == "latest" {
// Maximum uint64; the server clamps to current head.
return ^uint64(0), nil
}
var v uint64
if _, err := fmt.Sscanf(s, "%d", &v); err != nil {
return 0, fmt.Errorf("invalid --to-height %q: %w", s, err)
}
return v, nil
}
// waitForLuxd polls ${rpc}/ext/health until 200, capped at timeout.
func waitForLuxd(ctx context.Context, rpc string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
url := rpc + "/ext/health"
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := exportHTTPClient.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("luxd at %s never became healthy within %s", rpc, timeout)
}
// jsonRPCRequest and jsonRPCResponse are inlined to keep this binary
// dependency-light (no luxfi/sdk import — matches rlp-import's choice).
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params []interface{} `json:"params"`
ID int `json:"id"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
ID int `json:"id"`
}
// exportResult mirrors the server-side ExportChainResult so we can decode
// without importing the evm plugin (which would pull in geth/coreth and
// blow up the binary size for what is fundamentally an RPC client).
type exportResult struct {
Success bool `json:"success"`
Status string `json:"status"`
BlocksExported uint64 `json:"blocksExported"`
First uint64 `json:"first"`
Last uint64 `json:"last"`
HighestExported uint64 `json:"highestExported"`
FirstHash string `json:"firstHash"`
LastHash string `json:"lastHash"`
SentinelPath string `json:"sentinelPath"`
Message string `json:"message,omitempty"`
}
// callAdminExportChain POSTs admin_exportChain to the per-alias /ext/bc/
// endpoint. Returns the parsed result on success, err on luxd-side failure.
func callAdminExportChain(ctx context.Context, rpc, alias, file string, first, last uint64) (*exportResult, error) {
body, err := json.Marshal(jsonRPCRequest{
JSONRPC: "2.0",
// Geth/coreth admin namespace uses underscore notation:
// admin_exportChain (not admin.exportChain).
Method: "admin_exportChain",
Params: []interface{}{file, first, last},
ID: 1,
})
if err != nil {
return nil, err
}
url := rpc + "/ext/bc/" + alias + "/rpc"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if tok := strings.TrimSpace(os.Getenv("KMS_AUTH_TOKEN")); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := exportHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out jsonRPCResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("decode response: %w (body=%q)", err, string(raw))
}
if out.Error != nil {
return nil, fmt.Errorf("RPC error %d: %s", out.Error.Code, out.Error.Message)
}
var result exportResult
if err := json.Unmarshal(out.Result, &result); err != nil {
return nil, fmt.Errorf("decode result: %w (raw=%q)", err, string(out.Result))
}
return &result, nil
}
+420
View File
@@ -0,0 +1,420 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestParseFlags_RequiredFields covers the bad-input exit path: missing
// any of chain/output-file must error out without contacting luxd.
func TestParseFlags_RequiredFields(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"missing chain", []string{"--output-file=/data/exports/x.rlp"}, "chain"},
{"missing output-file", []string{"--chain=C"}, "output-file"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseFlags(tc.args)
if err == nil {
t.Fatalf("expected error containing %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %v, want substring %q", err, tc.want)
}
})
}
}
// TestParseFlags_Defaults pins the defaults so a future refactor that
// silently changes fromHeight=0 → fromHeight=1 (off-by-one bug magnet)
// shows up as a test failure.
func TestParseFlags_Defaults(t *testing.T) {
cfg, err := parseFlags([]string{"--chain=C", "--output-file=/x.rlp"})
if err != nil {
t.Fatal(err)
}
if cfg.fromHeight != 0 {
t.Errorf("default fromHeight = %d, want 0", cfg.fromHeight)
}
if cfg.toHeight != "latest" {
t.Errorf("default toHeight = %q, want %q", cfg.toHeight, "latest")
}
if cfg.luxdRPC != "http://127.0.0.1:9630" {
t.Errorf("default luxdRPC = %q", cfg.luxdRPC)
}
}
// TestResolveToHeight_Latest maps "latest" to math.MaxUint64 (the server
// clamps to current head). Any other behavior would break the
// --to-height=latest use case which the operator's hourly Job relies on.
func TestResolveToHeight_Latest(t *testing.T) {
got, err := resolveToHeight("latest")
if err != nil {
t.Fatal(err)
}
if got != ^uint64(0) {
t.Errorf("resolveToHeight(latest) = %d, want max uint64", got)
}
}
// TestResolveToHeight_Number ensures a numeric height passes through.
func TestResolveToHeight_Number(t *testing.T) {
got, err := resolveToHeight("1082780")
if err != nil {
t.Fatal(err)
}
if got != 1082780 {
t.Errorf("resolveToHeight(1082780) = %d, want 1082780", got)
}
}
// TestResolveToHeight_Garbage covers the bad-input gate.
func TestResolveToHeight_Garbage(t *testing.T) {
_, err := resolveToHeight("not-a-number")
if err == nil {
t.Error("expected error for garbage --to-height")
}
}
// TestLocalIdempotencyShortCircuit_DoneSentinelHit is the optimization
// proof: a previously-completed export of the same range short-circuits
// without contacting luxd.
func TestLocalIdempotencyShortCircuit_DoneSentinelHit(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "C-1082780.rlp")
if err := os.WriteFile(outFile, []byte("rlp-bytes"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outFile+".sentinel", []byte(`{
"status": "done",
"first": 0,
"last": 1082780,
"highestExported": 1082780,
"firstHash": "0x3f4fa2a0",
"lastHash": "0xdeadbeef"
}`), 0644); err != nil {
t.Fatal(err)
}
c := &config{
chainAlias: "C",
outputFile: outFile,
fromHeight: 0,
toHeight: "1082780",
}
code, ok := localIdempotencyShortCircuit(c)
if !ok {
t.Fatal("expected short-circuit hit")
}
if code != exitOK {
t.Errorf("code = %d, want %d", code, exitOK)
}
}
// TestLocalIdempotencyShortCircuit_LatestFallsThrough ensures the
// --to-height=latest case always falls through to the server (the local
// check can't resolve "latest" without asking luxd).
func TestLocalIdempotencyShortCircuit_LatestFallsThrough(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "x.rlp")
if err := os.WriteFile(outFile, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outFile+".sentinel", []byte(`{
"status": "done", "first": 0, "last": 99,
"highestExported": 99, "firstHash": "0x1", "lastHash": "0x2"
}`), 0644); err != nil {
t.Fatal(err)
}
c := &config{
chainAlias: "C",
outputFile: outFile,
fromHeight: 0,
toHeight: "latest", // <-- must fall through
}
if _, ok := localIdempotencyShortCircuit(c); ok {
t.Error("expected fall-through for --to-height=latest")
}
}
// TestLocalIdempotencyShortCircuit_MissingFile rejects the case where the
// sentinel claims done but the actual RLP file has been deleted.
func TestLocalIdempotencyShortCircuit_MissingFile(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "missing.rlp")
if err := os.WriteFile(outFile+".sentinel", []byte(`{
"status": "done", "first": 0, "last": 99,
"highestExported": 99, "firstHash": "0x1", "lastHash": "0x2"
}`), 0644); err != nil {
t.Fatal(err)
}
c := &config{
chainAlias: "C",
outputFile: outFile,
fromHeight: 0,
toHeight: "99",
}
if _, ok := localIdempotencyShortCircuit(c); ok {
t.Error("expected fall-through when output file missing")
}
}
// TestLocalIdempotencyShortCircuit_StatusInProgressFallsThrough covers the
// "previous run crashed mid-flight" case — the local check must not accept
// an in-progress sentinel as proof of completion.
func TestLocalIdempotencyShortCircuit_StatusInProgressFallsThrough(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "partial.rlp")
if err := os.WriteFile(outFile, []byte("partial"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outFile+".sentinel", []byte(`{
"status": "in_progress", "first": 0, "last": 1082780,
"highestExported": 512000, "firstHash": "0x1", "lastHash": "0x2"
}`), 0644); err != nil {
t.Fatal(err)
}
c := &config{
chainAlias: "C",
outputFile: outFile,
fromHeight: 0,
toHeight: "1082780",
}
if _, ok := localIdempotencyShortCircuit(c); ok {
t.Error("expected fall-through on in_progress sentinel")
}
}
// TestRun_HappyPath drives the full lifecycle against an httptest server
// that pretends to be luxd.
func TestRun_HappyPath(t *testing.T) {
var exportCalled bool
var exportBodyRaw []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/ext/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/ext/bc/"):
exportCalled = true
exportBodyRaw, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","result":{
"success": true,
"status": "ok",
"blocksExported": 1082781,
"first": 0,
"last": 1082780,
"highestExported": 1082780,
"firstHash": "0x3f4fa2a0",
"lastHash": "0xabcdef00",
"sentinelPath": "/data/exports/C-1082780.rlp.sentinel",
"message": "exported 1082781 blocks [0..1082780]"
},"id":1}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
dir := t.TempDir()
c := &config{
chainAlias: "C",
outputFile: filepath.Join(dir, "C-1082780.rlp"),
fromHeight: 0,
toHeight: "1082780",
luxdRPC: srv.URL,
}
code, err := run(context.Background(), c)
if code != exitOK {
t.Fatalf("code = %d (err=%v), want %d", code, err, exitOK)
}
if !exportCalled {
t.Error("admin_exportChain was not called")
}
var req jsonRPCRequest
if err := json.Unmarshal(exportBodyRaw, &req); err != nil {
t.Fatalf("decode: %v", err)
}
if req.Method != "admin_exportChain" {
t.Errorf("method = %q, want admin_exportChain", req.Method)
}
if len(req.Params) != 3 {
t.Fatalf("params len = %d, want 3", len(req.Params))
}
if got, _ := req.Params[0].(string); got != c.outputFile {
t.Errorf("params[0] (file) = %q, want %q", got, c.outputFile)
}
}
// TestRun_NoopFromServer covers the "server returned status=noop" branch
// (a different host called rlp-export against an already-exported file).
func TestRun_NoopFromServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/ext/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/ext/bc/"):
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","result":{
"success": true,
"status": "noop",
"blocksExported": 100,
"first": 0,
"last": 99,
"highestExported": 99,
"firstHash": "0x1",
"lastHash": "0x2",
"sentinelPath": "x"
},"id":1}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
dir := t.TempDir()
c := &config{
chainAlias: "C",
outputFile: filepath.Join(dir, "noop.rlp"),
fromHeight: 0,
toHeight: "99",
luxdRPC: srv.URL,
}
code, err := run(context.Background(), c)
if code != exitOK {
t.Errorf("code = %d (err=%v), want %d", code, err, exitOK)
}
}
// TestRun_InterruptedFromServer covers the partial-completion exit code.
// The K8s Job restart policy reads this to schedule a resume run.
func TestRun_InterruptedFromServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/ext/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/ext/bc/"):
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","result":{
"success": false,
"status": "interrupted",
"blocksExported": 512000,
"first": 0,
"last": 1082780,
"highestExported": 512000,
"firstHash": "0x1",
"lastHash": "0x2",
"sentinelPath": "x"
},"id":1}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
dir := t.TempDir()
c := &config{
chainAlias: "C",
outputFile: filepath.Join(dir, "C.rlp"),
fromHeight: 0,
toHeight: "1082780",
luxdRPC: srv.URL,
}
code, _ := run(context.Background(), c)
if code != exitInterrupted {
t.Errorf("code = %d, want %d (interrupted)", code, exitInterrupted)
}
}
// TestRun_LuxdUnreachable covers the transient-failure exit code.
func TestRun_LuxdUnreachable(t *testing.T) {
// Use a closed port — no server bound there.
c := &config{
chainAlias: "C",
outputFile: filepath.Join(t.TempDir(), "x.rlp"),
fromHeight: 0,
toHeight: "99",
luxdRPC: "http://127.0.0.1:1", // closed
}
// Short timeout via context so the test runs in seconds.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
code, err := run(ctx, c)
// Either we hit the health-check timeout (exitLuxdUnreach) or the
// outer context cancels us before the deadline (also acceptable —
// both indicate "luxd not there").
if code != exitLuxdUnreach && code != exitInterrupted {
t.Errorf("code = %d (err=%v), want %d or %d",
code, err, exitLuxdUnreach, exitInterrupted)
}
}
// TestRun_AdminRPCError covers a luxd-side RPC failure (admin API disabled
// is the common case in production envs that haven't opted in).
func TestRun_AdminRPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/ext/health":
w.WriteHeader(http.StatusOK)
case strings.HasPrefix(r.URL.Path, "/ext/bc/"):
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","error":{"code":-32601,"message":"admin API not enabled"},"id":1}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := &config{
chainAlias: "C",
outputFile: filepath.Join(t.TempDir(), "x.rlp"),
fromHeight: 0,
toHeight: "99",
luxdRPC: srv.URL,
}
code, err := run(context.Background(), c)
if code != exitAdminRPCError {
t.Errorf("code = %d, want %d", code, exitAdminRPCError)
}
if err == nil || !strings.Contains(err.Error(), "admin API not enabled") {
t.Errorf("err = %v, want admin API not enabled", err)
}
}
// TestCallAdminExportChain_BearerAuthSet asserts KMS_AUTH_TOKEN flows into
// the Authorization header — symmetric with rlp-import's same gate.
func TestCallAdminExportChain_BearerAuthSet(t *testing.T) {
t.Setenv("KMS_AUTH_TOKEN", "secret-token")
var sawAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","result":{
"success": true, "status": "ok",
"blocksExported": 1, "first": 0, "last": 0,
"highestExported": 0, "firstHash": "0x1", "lastHash": "0x1",
"sentinelPath": "x"
},"id":1}`))
}))
defer srv.Close()
_, err := callAdminExportChain(context.Background(), srv.URL, "C", "/x.rlp", 0, 0)
if err != nil {
t.Fatal(err)
}
if sawAuth != "Bearer secret-token" {
t.Errorf("Authorization = %q, want %q", sawAuth, "Bearer secret-token")
}
}