diff --git a/Makefile b/Makefile index ca54094..3b9c150 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... diff --git a/cmd/rlp-export/main.go b/cmd/rlp-export/main.go new file mode 100644 index 0000000..8e77b88 --- /dev/null +++ b/cmd/rlp-export/main.go @@ -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 .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 +} diff --git a/cmd/rlp-export/main_test.go b/cmd/rlp-export/main_test.go new file mode 100644 index 0000000..18f6430 --- /dev/null +++ b/cmd/rlp-export/main_test.go @@ -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") + } +}