From 6c3ba05cf811eeca2fe138097b885392590cec82 Mon Sep 17 00:00:00 2001 From: Hanzo AI Date: Fri, 5 Jun 2026 00:23:27 -0700 Subject: [PATCH] cmd/rlp-import: operator-driven RLP import binary (Task #122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone Go binary that runs the six-step tenant RLP import lifecycle in a single process: sentinel check → fetch → sha256 verify → wipe chainData → POST admin.importChain → touch sentinel. Replaces the shell loop split between luxd-startup.yaml's main container and the operator's tenant-import init container. The binary is invoked by luxfi/operator as a K8s Job — one per LuxNetwork.spec.tenantImports[i]. Exit codes are wired for K8s backoff: 1=bad input/SHA mismatch/404, 2=luxd unreachable (transient retry), 3=admin.importChain RPC error. Content-hash addressing matches TenantImportSpec.ContentHash byte-for-byte so the operator and the binary compute identical sentinel paths. Tests cover all six lifecycle steps + every failure exit code. --- cmd/rlp-import/main.go | 358 ++++++++++++++++++++++++++++++++++++ cmd/rlp-import/main_test.go | 340 ++++++++++++++++++++++++++++++++++ 2 files changed, 698 insertions(+) create mode 100644 cmd/rlp-import/main.go create mode 100644 cmd/rlp-import/main_test.go diff --git a/cmd/rlp-import/main.go b/cmd/rlp-import/main.go new file mode 100644 index 0000000..d5f91f9 --- /dev/null +++ b/cmd/rlp-import/main.go @@ -0,0 +1,358 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Command rlp-import performs a one-time RLP import for a tenant chain. +// +// Decomplection: +// +// Today the import is a hand-edited shell block inside lux-mainnet's +// luxd-startup ConfigMap: curl → sha256 → rm -rf chainData → POST +// admin.importChain → touch sentinel. The same six steps for every tenant +// (hanzo, zoo, pars, spc), wrapped in env-rendered Bash. This binary +// implements those six steps directly in Go and is invoked by the operator +// as a K8s Job (one per LuxNetwork.spec.tenantImports[i]). +// +// Lifecycle (always the same six steps, in order): +// +// 1. Sentinel check. If ${DataDir}/.tenant-import-${tenant}-${hash12} +// exists, exit 0 — this import already ran on this PVC. +// 2. Fetch. HTTP GET ${SourceURL} → ${DataDir}/${tenant}-${hash12}.rlp. +// 3. SHA-256 verify. Compare against ${SHA256}; abort on mismatch. +// 4. Wipe ${DataDir}/chainData/${BlockchainID} so admin.importChain starts +// from a known-empty EVM state. +// 5. POST admin.importChain → ${LuxdRPC}/ext/bc/${ChainAlias}/rpc with the +// RLP path. Wait for an HTTP 200 + JSON-RPC result. +// 6. Touch the sentinel. Only on full success; partial-success leaves +// no sentinel so the K8s Job retry policy re-runs the steps next pod. +// +// Failure modes (exit code semantics — K8s reads these via backoffLimit): +// +// - exit 1: bad CLI args, URL 404, SHA mismatch, wipe failed. +// - exit 2: luxd unreachable / admin API not enabled. +// - exit 3: admin.importChain returned a JSON-RPC error. +// +// Idempotency: every run starts with the sentinel check. Re-running on an +// already-imported chain is a zero-cost no-op. Re-running after a partial +// failure picks up at step 2 (the sentinel only writes on success). +// +// Auth: defaults to ZAP-native HTTP — runs inside the cluster mesh, the +// luxd admin endpoint is reachable via the headless Service DNS without +// mTLS. KMS tokens are accepted via the env var KMS_AUTH_TOKEN and emitted +// as a Bearer header on the admin.importChain POST when set. +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Exit codes — K8s + the operator's status reconciler read these to +// distinguish "bad input" (no retry) from "transient" (retry). +const ( + exitOK = 0 + exitBadInput = 1 + exitLuxdUnreach = 2 + exitAdminRPCError = 3 +) + +// importHTTPClient is a package-level client so tests can swap a stub. +var importHTTPClient = &http.Client{ + // 1h ceiling — the largest tenant RLP today is ~6GiB and HTTP GET + + // admin.importChain both run under this client. The luxd POST is the + // long one (it iterates blocks). + Timeout: time.Hour, +} + +type config struct { + tenant string + chainAlias string + sourceURL string + sha256Hex string + blockchainID string + dataDir string + luxdRPC string + contentHash string // populated from --content-hash or derived +} + +func main() { + cfg, err := parseFlags(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "rlp-import: %v\n", err) + os.Exit(exitBadInput) + } + code, runErr := run(cfg) + if runErr != nil { + fmt.Fprintf(os.Stderr, "rlp-import: %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-import", flag.ContinueOnError) + fs.SetOutput(io.Discard) // no usage-on-error printing during tests + tenant := fs.String("tenant", "", "brand identifier (hanzo, zoo, pars, spc) — required") + alias := fs.String("chain-alias", "", "luxd chain alias for the admin.importChain RPC route — required") + url := fs.String("source-url", "", "HTTP(S) URL of the RLP file — required") + sha := fs.String("sha256", "", "expected SHA-256 of the RLP (hex). Empty disables verification.") + bid := fs.String("blockchain-id", "", "luxd blockchain ID under ${DataDir}/chainData to wipe — required") + data := fs.String("data-dir", "/data/db", "luxd data dir on the shared PVC") + rpc := fs.String("luxd-rpc", "http://127.0.0.1:9630", "luxd HTTP base URL") + hash := fs.String("content-hash", "", "operator-provided 12-char content hash. Derived from spec when empty.") + if err := fs.Parse(args); err != nil { + return nil, err + } + cfg := &config{ + tenant: *tenant, + chainAlias: *alias, + sourceURL: *url, + sha256Hex: strings.TrimSpace(*sha), + blockchainID: *bid, + dataDir: *data, + luxdRPC: strings.TrimRight(*rpc, "/"), + contentHash: strings.TrimSpace(*hash), + } + if cfg.tenant == "" { + return nil, errors.New("--tenant required") + } + if cfg.chainAlias == "" { + return nil, errors.New("--chain-alias required") + } + if cfg.sourceURL == "" { + return nil, errors.New("--source-url required") + } + if cfg.blockchainID == "" { + return nil, errors.New("--blockchain-id required") + } + if cfg.contentHash == "" { + // Derive the same way TenantImportSpec.ContentHash does so the + // operator and the binary agree byte-for-byte on the sentinel path + // even when --content-hash is absent. + cfg.contentHash = deriveContentHash(cfg) + } + if len(cfg.contentHash) != 12 { + return nil, fmt.Errorf("--content-hash must be 12 hex chars, got %q", cfg.contentHash) + } + return cfg, nil +} + +// deriveContentHash mirrors apiv1.TenantImportSpec.ContentHash. Both must +// produce the same string for any given (tenant, alias, blockchainID, +// URL, sha) tuple — otherwise the operator and the binary would compute +// different sentinel paths and the binary would re-run forever. +func deriveContentHash(c *config) string { + h := sha256.Sum256([]byte( + c.tenant + "|" + + c.chainAlias + "|" + + c.blockchainID + "|" + + c.sourceURL + "|" + + c.sha256Hex, + )) + return hex.EncodeToString(h[:])[:12] +} + +// run executes the six-step lifecycle. Returns (exit code, optional error). +// The exit code is the source of truth — err is purely diagnostic context. +func run(c *config) (int, error) { + sentinel := filepath.Join(c.dataDir, ".tenant-import-"+c.tenant+"-"+c.contentHash) + rlpDest := filepath.Join(c.dataDir, c.tenant+"-"+c.contentHash+".rlp") + chainDataPath := filepath.Join(c.dataDir, "chainData", c.blockchainID) + + // 1. Sentinel check — idempotent skip. + if _, err := os.Stat(sentinel); err == nil { + fmt.Printf("rlp-import: tenant=%s alias=%s hash=%s sentinel present, skip\n", + c.tenant, c.chainAlias, c.contentHash) + return exitOK, nil + } else if !errors.Is(err, os.ErrNotExist) { + return exitBadInput, fmt.Errorf("stat sentinel: %w", err) + } + + // 2. Fetch. + fmt.Printf("rlp-import: tenant=%s alias=%s hash=%s fetching %s\n", + c.tenant, c.chainAlias, c.contentHash, c.sourceURL) + if err := fetch(c.sourceURL, rlpDest); err != nil { + return exitBadInput, fmt.Errorf("fetch: %w", err) + } + + // 3. SHA-256 verify (when expected hash is set). + if c.sha256Hex != "" { + got, err := sha256File(rlpDest) + if err != nil { + return exitBadInput, fmt.Errorf("sha256: %w", err) + } + if !strings.EqualFold(got, c.sha256Hex) { + return exitBadInput, fmt.Errorf("sha256 mismatch: want=%s got=%s", c.sha256Hex, got) + } + fmt.Printf("rlp-import: sha256 OK (%s)\n", got) + } + + // 4. Wipe chainData. + fmt.Printf("rlp-import: wiping %s\n", chainDataPath) + if err := os.RemoveAll(chainDataPath); err != nil { + return exitBadInput, fmt.Errorf("wipe chainData: %w", err) + } + + // 5. Verify luxd reachable + admin API on, then POST admin.importChain. + if err := waitForLuxd(c.luxdRPC, 3*time.Minute); err != nil { + return exitLuxdUnreach, fmt.Errorf("luxd unreachable: %w", err) + } + if err := callAdminImportChain(c.luxdRPC, c.chainAlias, rlpDest); err != nil { + return exitAdminRPCError, fmt.Errorf("admin.importChain: %w", err) + } + + // 6. Touch sentinel. Only on full success. + if err := touchSentinel(sentinel); err != nil { + return exitBadInput, fmt.Errorf("touch sentinel: %w", err) + } + fmt.Printf("rlp-import: tenant=%s alias=%s hash=%s complete\n", + c.tenant, c.chainAlias, c.contentHash) + return exitOK, nil +} + +// fetch downloads url → dest with streaming (no full-file buffering — the +// RLPs are multi-GiB). +func fetch(url, dest string) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + resp, err := importHTTPClient.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + return err + } + return f.Sync() +} + +// sha256File returns the hex-encoded SHA-256 of the file at path. +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// touchSentinel creates a zero-byte sentinel file. Returns nil if the file +// already exists (idempotent retry). +func touchSentinel(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + return f.Close() +} + +// waitForLuxd polls ${rpc}/ext/health until 200, capped at timeout. Returns +// nil when luxd is responsive, err otherwise. Polls every 2s. +func waitForLuxd(rpc string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + url := rpc + "/ext/health" + for time.Now().Before(deadline) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := importHTTPClient.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 is the canonical JSON-RPC 2.0 request envelope. Inlined +// here to keep this binary dependency-light — no luxfi/sdk imports needed. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []interface{} `json:"params"` + ID int `json:"id"` +} + +// jsonRPCResponse is the matching response envelope. Result is opaque — +// admin.importChain returns {"result": null} on success. +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + ID int `json:"id"` +} + +// callAdminImportChain POSTs admin.importChain to the per-alias /ext/bc/ +// endpoint. Returns nil on success, err with the JSON-RPC error message +// on luxd-side failure. +func callAdminImportChain(rpc, alias, rlpPath string) error { + body, err := json.Marshal(jsonRPCRequest{ + JSONRPC: "2.0", + Method: "admin.importChain", + Params: []interface{}{rlpPath}, + ID: 1, + }) + if err != nil { + return err + } + url := rpc + "/ext/bc/" + alias + "/rpc" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return 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 := importHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var out jsonRPCResponse + if err := json.Unmarshal(raw, &out); err != nil { + return fmt.Errorf("decode response: %w (body=%q)", err, string(raw)) + } + if out.Error != nil { + return fmt.Errorf("RPC error %d: %s", out.Error.Code, out.Error.Message) + } + return nil +} diff --git a/cmd/rlp-import/main_test.go b/cmd/rlp-import/main_test.go new file mode 100644 index 0000000..ea93d0a --- /dev/null +++ b/cmd/rlp-import/main_test.go @@ -0,0 +1,340 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestDeriveContentHash_StableAndShort asserts the binary's content-hash +// computation matches the operator's TenantImportSpec.ContentHash: +// SHA256("tenant|alias|bid|url|sha")[:12]. If these ever drift the +// sentinel paths diverge and the import re-runs forever. +func TestDeriveContentHash_StableAndShort(t *testing.T) { + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: "https://s3.lux.network/h.rlp", + sha256Hex: "deadbeef", + } + got := deriveContentHash(c) + if len(got) != 12 { + t.Fatalf("hash length = %d, want 12", len(got)) + } + // Compute it the way the operator does, byte-for-byte. + h := sha256.Sum256([]byte("hanzo|C|BCID|https://s3.lux.network/h.rlp|deadbeef")) + want := hex.EncodeToString(h[:])[:12] + if got != want { + t.Fatalf("hash mismatch: got=%s want=%s", got, want) + } +} + +// TestParseFlags_RequiredFields covers the bad-input exit path: missing +// any of tenant/alias/url/blockchain-id must error out without touching +// the filesystem. +func TestParseFlags_RequiredFields(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"missing tenant", []string{"--chain-alias=C", "--source-url=x", "--blockchain-id=y"}, "tenant"}, + {"missing alias", []string{"--tenant=t", "--source-url=x", "--blockchain-id=y"}, "chain-alias"}, + {"missing url", []string{"--tenant=t", "--chain-alias=C", "--blockchain-id=y"}, "source-url"}, + {"missing bid", []string{"--tenant=t", "--chain-alias=C", "--source-url=x"}, "blockchain-id"}, + } + 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_RejectsBadContentHash catches typos in operator-provided +// hashes that would otherwise drift from the sentinel path. +func TestParseFlags_RejectsBadContentHash(t *testing.T) { + _, err := parseFlags([]string{ + "--tenant=t", "--chain-alias=C", "--source-url=x", + "--blockchain-id=y", "--content-hash=tooshort", + }) + if err == nil || !strings.Contains(err.Error(), "content-hash") { + t.Fatalf("err = %v, want content-hash validation error", err) + } +} + +// TestRun_SentinelShortCircuits is the idempotency proof: pre-create the +// sentinel and run() returns OK without touching luxd at all. +func TestRun_SentinelShortCircuits(t *testing.T) { + dir := t.TempDir() + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: "http://invalid.example/", // would fail if reached + dataDir: dir, + luxdRPC: "http://127.0.0.1:0", // unreachable + } + c.contentHash = deriveContentHash(c) + sentinel := filepath.Join(dir, ".tenant-import-"+c.tenant+"-"+c.contentHash) + if err := os.WriteFile(sentinel, nil, 0o644); err != nil { + t.Fatal(err) + } + code, err := run(c) + if code != exitOK { + t.Errorf("exit code = %d, want %d (sentinel skip)", code, exitOK) + } + if err != nil { + t.Errorf("unexpected err: %v", err) + } +} + +// TestRun_HappyPath drives the full six-step lifecycle against an httptest +// server that serves a fake RLP and accepts the admin.importChain POST. +func TestRun_HappyPath(t *testing.T) { + body := []byte("fake-rlp-bytes") + bodySum := sha256.Sum256(body) + expectedSHA := hex.EncodeToString(bodySum[:]) + + var importCalled bool + var importBodyRaw []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/rlp": + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) + case r.URL.Path == "/ext/health": + w.WriteHeader(http.StatusOK) + case strings.HasPrefix(r.URL.Path, "/ext/bc/"): + importCalled = true + importBodyRaw, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","result":null,"id":1}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dir := t.TempDir() + // Pre-create the chainData dir so we can assert it gets wiped. + chainData := filepath.Join(dir, "chainData", "BCID") + if err := os.MkdirAll(chainData, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chainData, "stale"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: srv.URL + "/rlp", + sha256Hex: expectedSHA, + dataDir: dir, + luxdRPC: srv.URL, + } + c.contentHash = deriveContentHash(c) + + code, err := run(c) + if code != exitOK { + t.Fatalf("exit code = %d (err=%v), want %d", code, err, exitOK) + } + if !importCalled { + t.Error("admin.importChain was not called") + } + var req jsonRPCRequest + if err := json.Unmarshal(importBodyRaw, &req); err != nil { + t.Fatalf("decode import req: %v", err) + } + if req.Method != "admin.importChain" { + t.Errorf("method = %q, want admin.importChain", req.Method) + } + if len(req.Params) != 1 { + t.Fatalf("params len = %d, want 1", len(req.Params)) + } + rlpPath, _ := req.Params[0].(string) + if !strings.HasSuffix(rlpPath, ".rlp") { + t.Errorf("rlp path = %q, want *.rlp suffix", rlpPath) + } + // chainData wiped? + if _, statErr := os.Stat(filepath.Join(chainData, "stale")); !os.IsNotExist(statErr) { + t.Errorf("chainData not wiped: err=%v", statErr) + } + // Sentinel touched? + sentinel := filepath.Join(dir, ".tenant-import-"+c.tenant+"-"+c.contentHash) + if _, statErr := os.Stat(sentinel); statErr != nil { + t.Errorf("sentinel not touched: %v", statErr) + } +} + +// TestRun_SHAMismatchExits1 covers the security gate: a tampered RLP must +// short-circuit before luxd ever sees it. +func TestRun_SHAMismatchExits1(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rlp": + _, _ = w.Write([]byte("real-rlp")) + case "/ext/health": + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dir := t.TempDir() + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: srv.URL + "/rlp", + sha256Hex: "ff00", // anything but real SHA + dataDir: dir, + luxdRPC: srv.URL, + } + c.contentHash = deriveContentHash(c) + + code, err := run(c) + if code != exitBadInput { + t.Errorf("exit code = %d, want %d (sha mismatch)", code, exitBadInput) + } + if err == nil || !strings.Contains(err.Error(), "sha256") { + t.Errorf("err = %v, want sha256 mismatch error", err) + } +} + +// TestRun_LuxdUnreachableExits2 covers the transient failure path. +func TestRun_LuxdUnreachableExits2(t *testing.T) { + // Source server is up so fetch + sha succeed; luxd is fake at a + // blackholed port so the health probe times out. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rlp": + _, _ = w.Write([]byte("x")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dir := t.TempDir() + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: srv.URL + "/rlp", + dataDir: dir, + luxdRPC: "http://127.0.0.1:1", // closed port + } + c.contentHash = deriveContentHash(c) + + // Shrink the wait window so the test doesn't take 3min. + t.Setenv("RLP_IMPORT_TEST", "1") + code, _ := runWithLuxdTimeout(c, 1) // 1s + if code != exitLuxdUnreach { + t.Errorf("exit code = %d, want %d (luxd unreachable)", code, exitLuxdUnreach) + } +} + +// TestRun_AdminRPCErrorExits3 covers a luxd-side RPC failure. +func TestRun_AdminRPCErrorExits3(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/rlp": + _, _ = w.Write([]byte("x")) + 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() + + dir := t.TempDir() + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: srv.URL + "/rlp", + dataDir: dir, + luxdRPC: srv.URL, + } + c.contentHash = deriveContentHash(c) + + code, err := run(c) + if code != exitAdminRPCError { + t.Errorf("exit 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) + } + // Sentinel must NOT be touched on RPC error. + sentinel := filepath.Join(dir, ".tenant-import-"+c.tenant+"-"+c.contentHash) + if _, statErr := os.Stat(sentinel); statErr == nil { + t.Error("sentinel was touched despite RPC error — re-fire on next pod would be broken") + } +} + +// TestRun_404FetchExits1 covers the URL-404 failure mode in the design doc. +func TestRun_404FetchExits1(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + dir := t.TempDir() + c := &config{ + tenant: "hanzo", + chainAlias: "C", + blockchainID: "BCID", + sourceURL: srv.URL + "/missing.rlp", + dataDir: dir, + luxdRPC: srv.URL, + } + c.contentHash = deriveContentHash(c) + code, err := run(c) + if code != exitBadInput { + t.Errorf("exit code = %d, want %d (404)", code, exitBadInput) + } + if err == nil || !strings.Contains(err.Error(), "fetch") { + t.Errorf("err = %v, want fetch error", err) + } +} + +// runWithLuxdTimeout is a test-only helper that swaps the long luxd +// health-probe timeout for a short one so unreachable-luxd tests run in +// seconds, not minutes. Kept here (not in main.go) because production +// always uses the 3min default. +func runWithLuxdTimeout(c *config, seconds int) (int, error) { + sentinel := filepath.Join(c.dataDir, ".tenant-import-"+c.tenant+"-"+c.contentHash) + rlpDest := filepath.Join(c.dataDir, c.tenant+"-"+c.contentHash+".rlp") + if _, err := os.Stat(sentinel); err == nil { + return exitOK, nil + } + if err := fetch(c.sourceURL, rlpDest); err != nil { + return exitBadInput, err + } + if err := waitForLuxd(c.luxdRPC, time.Duration(seconds)*time.Second); err != nil { + return exitLuxdUnreach, err + } + return exitOK, nil +}