mirror of
https://github.com/luxfi/genesis.git
synced 2026-07-27 04:11:41 +00:00
cmd/rlp-import: operator-driven RLP import binary (Task #122)
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user