mirror of
https://github.com/luxfi/genesis.git
synced 2026-07-26 23:48:54 +00:00
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:
@@ -27,6 +27,29 @@ build:
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(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
|
||||
test:
|
||||
$(GOTEST) -v -race ./...
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user