diff --git a/cmd/hiveview/assets/lib/app-suite.js b/cmd/hiveview/assets/lib/app-suite.js index 01111f30dc..a380a164df 100644 --- a/cmd/hiveview/assets/lib/app-suite.js +++ b/cmd/hiveview/assets/lib/app-suite.js @@ -317,21 +317,175 @@ function deselectTest(row, closeDetails) { history.replaceState(null, null, '#'); } -function testHasClients(testData) { - return testData.clientInfo && Object.getOwnPropertyNames(testData.clientInfo).length > 0; +function testHasClients(testData, suiteData) { + if (testData.clientInfo && Object.getOwnPropertyNames(testData.clientInfo).length > 0) { + return true; + } + + if (testData.summaryResult && testData.summaryResult.clientLogs && + Object.keys(testData.summaryResult.clientLogs).length > 0) { + return true; + } + + if (suiteData && suiteData.clientInfo) { + for (let clientID in suiteData.clientInfo) { + let clientName = suiteData.clientInfo[clientID].name; + if (testData.name.includes(clientName)) { + return true; + } + } + } + + return false; } // formatClientLogsList turns the clientInfo part of a test into a list of links. function formatClientLogsList(suiteData, testIndex, clientInfo) { let links = []; - for (let instanceID in clientInfo) { - let instanceInfo = clientInfo[instanceID]; - let logfile = routes.resultsRoot + instanceInfo.logFile; - let url = routes.clientLog(suiteData.suiteID, suiteData.name, testIndex, logfile); - let link = html.makeLink(url, instanceInfo.name); - link.classList.add('log-link'); - links.push(link.outerHTML); + let testCase = suiteData.testCases[testIndex]; + let usedSharedClients = new Set(); // Track which shared clients were used in this test + + // First, check if the test has specific information about which shared clients it used + if (testCase && testCase.summaryResult && testCase.summaryResult.clientLogs) { + for (let clientID in testCase.summaryResult.clientLogs) { + usedSharedClients.add(clientID); + } + } + + // Handle clients listed directly in the test's clientInfo + if (clientInfo) { + for (let instanceID in clientInfo) { + let instanceInfo = clientInfo[instanceID]; + + // Skip if no log file + if (!instanceInfo.logFile) { + continue; + } + + // If it's a shared client, mark it as used + if (instanceInfo.isShared) { + usedSharedClients.add(instanceID); + } + + let logfile = routes.resultsRoot + instanceInfo.logFile; + let url = routes.clientLog(suiteData.suiteID, suiteData.name, testIndex, logfile); + + // Check if this is a shared client with a log segment + let hasSegment = testCase && + testCase.summaryResult && + testCase.summaryResult.clientLogs && + testCase.summaryResult.clientLogs[instanceID]; + + if (hasSegment) { + // If we have a log segment, update the URL to include the line numbers + const clientLogInfo = testCase.summaryResult.clientLogs[instanceID]; + + // Use line numbers from the backend + url += `#L${clientLogInfo.startLine}-${clientLogInfo.endLine}`; + } + + // Add "(shared)" indicator for shared clients + let clientName = instanceInfo.name; + if (instanceInfo.isShared || hasSegment) { + clientName += " (shared)"; + } + + let link = html.makeLink(url, clientName); + link.classList.add('log-link'); + if (instanceInfo.isShared) { + link.classList.add('shared-client-log'); + } + links.push(link.outerHTML); + } + } + + // For backward compatibility - if test name includes client name, add that client + // This handles the case where tests don't yet have clientInfo or clientLogs properly populated + if (suiteData.clientInfo) { + + // First try to match by existing client logs + if (usedSharedClients.size === 0) { + // Group clients by name to identify if there are multiple of the same type + let clientsByName = {}; + for (let instanceID in suiteData.clientInfo) { + let sharedClient = suiteData.clientInfo[instanceID]; + if (!sharedClient.logFile) continue; // Skip if no log file + + // Add to the clients by name map + if (!clientsByName[sharedClient.name]) { + clientsByName[sharedClient.name] = []; + } + clientsByName[sharedClient.name].push({id: instanceID, client: sharedClient}); + } + + // Now check test name for client match, but only if there's exactly one client of that type + for (let clientName in clientsByName) { + if (testCase.name.includes(clientName) && clientsByName[clientName].length === 1) { + // If there's exactly one client of this type, it's safe to auto-register + let instanceID = clientsByName[clientName][0].id; + usedSharedClients.add(instanceID); + } + } + } + + // Now add all the used shared clients that haven't been handled yet + for (let instanceID in suiteData.clientInfo) { + // First check if this client is explicitly registered in the test's clientLogs + // This is the most reliable way to determine if a client was used in a test + const explicitlyRegistered = testCase && + testCase.summaryResult && + testCase.summaryResult.clientLogs && + testCase.summaryResult.clientLogs[instanceID]; + + if (explicitlyRegistered) { + usedSharedClients.add(instanceID); + } + + // Skip if not used by this test (based on explicit tracking or name matching) + if (!usedSharedClients.has(instanceID)) { + continue; + } + + // Skip clients already handled in clientInfo + if (clientInfo && instanceID in clientInfo) { + continue; + } + + let sharedClient = suiteData.clientInfo[instanceID]; + + // Skip if no log file + if (!sharedClient.logFile) { + continue; + } + + // Create a link to the full log file for this shared client + let logfile = routes.resultsRoot + sharedClient.logFile; + let url = routes.clientLog(suiteData.suiteID, suiteData.name, testIndex, logfile); + + // Check if we have specific log segments for this client in this test + let hasSegment = testCase && + testCase.summaryResult && + testCase.summaryResult.clientLogs && + testCase.summaryResult.clientLogs[instanceID]; + + if (hasSegment) { + // If we have a log segment, update the URL to include the line numbers + const clientLogInfo = testCase.summaryResult.clientLogs[instanceID]; + + // Only add line range if we have valid line numbers (both > 0) + if (clientLogInfo.startLine > 0 && clientLogInfo.endLine > 0) { + url += `#L${clientLogInfo.startLine}-${clientLogInfo.endLine}`; + } + } + + let clientName = sharedClient.name + " (shared)"; + let link = html.makeLink(url, clientName); + + link.classList.add('log-link', 'shared-client-log'); + links.push(link.outerHTML); + } } + return links.join(', '); } @@ -360,7 +514,7 @@ function formatTestDetails(suiteData, row) { p.innerHTML = formatTestStatus(d.summaryResult); container.appendChild(p); } - if (!row.column('logs:name').responsiveHidden() && testHasClients(d)) { + if (!row.column('logs:name').responsiveHidden() && testHasClients(d, suiteData)) { let p = document.createElement('p'); p.innerHTML = 'Clients: ' + formatClientLogsList(suiteData, d.testIndex, d.clientInfo); container.appendChild(p); diff --git a/cmd/hiveview/assets/lib/app-viewer.js b/cmd/hiveview/assets/lib/app-viewer.js index 887492638b..e86d93363b 100644 --- a/cmd/hiveview/assets/lib/app-viewer.js +++ b/cmd/hiveview/assets/lib/app-viewer.js @@ -12,10 +12,16 @@ import { formatBytes, queryParam } from './utils.js'; $(document).ready(function () { common.updateHeader(); - // Check for line number in hash. - var line = null; - if (window.location.hash.substr(1, 1) == 'L') { - line = parseInt(window.location.hash.substr(2)); + // Check for line number or line range in hash. + var startLine = null; + var endLine = null; + var hash = window.location.hash.substr(1); + if (hash.startsWith('L')) { + var range = hash.substr(1).split('-'); + startLine = parseInt(range[0]); + if (range.length > 1) { + endLine = parseInt(range[1]); + } } // Get suite context. @@ -33,7 +39,7 @@ $(document).ready(function () { showError('Invalid parameters! Missing \'suitefile\' or \'testid\' in URL.'); return; } - fetchTestLog(routes.resultsRoot + suiteFile, testIndex, line); + fetchTestLog(routes.resultsRoot + suiteFile, testIndex, startLine, endLine); return; } @@ -42,7 +48,7 @@ $(document).ready(function () { if (file) { $('#fileload').val(file); showText('Loading file...'); - fetchFile(file, line); + fetchFile(file, startLine, endLine); return; } @@ -50,27 +56,53 @@ $(document).ready(function () { showText(document.getElementById('exampletext').innerHTML); }); -// setHL sets the highlight on a line number. -function setHL(num, scroll) { +// setHL sets the highlight on a line number or range of lines. +function setHL(startLine, endLine, scroll) { // out with the old $('.highlighted').removeClass('highlighted'); - if (!num) { + if (!startLine) { return; } let contentArea = document.getElementById('file-content'); let gutter = document.getElementById('gutter'); - let numElem = gutter.children[num - 1]; - if (!numElem) { - console.error('invalid line number:', num); - return; + + // Calculate the end line if not provided + if (!endLine) { + endLine = startLine; + } + + // Calculate max available lines and adjust range if needed + const maxLines = gutter.children.length; + + // Check if the requested range is beyond the file size + if (startLine > maxLines) { + startLine = 1; + } + + if (endLine > maxLines) { + endLine = maxLines; } - // in with the new - let lineElem = contentArea.children[num - 1]; - $(numElem).addClass('highlighted'); - $(lineElem).addClass('highlighted'); + + // Highlight all lines in the adjusted range + for (let num = startLine; num <= endLine; num++) { + let numElem = gutter.children[num - 1]; + if (!numElem) { + // Skip invalid line numbers + continue; + } + + let lineElem = contentArea.children[num - 1]; + $(numElem).addClass('highlighted'); + $(lineElem).addClass('highlighted'); + } + + // Scroll to the start of the highlighted range if (scroll) { - numElem.scrollIntoView(); + let firstNumElem = gutter.children[startLine - 1]; + if (firstNumElem) { + firstNumElem.scrollIntoView(); + } } } @@ -163,12 +195,12 @@ function appendLine(contentArea, gutter, number, text) { } function lineNumberClicked() { - setHL($(this).attr('line'), false); + setHL(parseInt($(this).attr('line')), null, false); history.replaceState(null, null, '#' + $(this).attr('id')); } // fetchFile loads up a new file to view -async function fetchFile(url, line /* optional jump to line */ ) { +async function fetchFile(url, startLine, endLine) { let resultsRE = new RegExp('^' + routes.resultsRoot); let text; try { @@ -181,11 +213,11 @@ async function fetchFile(url, line /* optional jump to line */ ) { let title = url.replace(resultsRE, ''); showTitle(null, title); showText(text); - setHL(line, true); + setHL(startLine, endLine, true); } // fetchTestLog loads the suite file and displays the output of a test. -async function fetchTestLog(suiteFile, testIndex, line) { +async function fetchTestLog(suiteFile, testIndex, startLine, endLine) { let data; try { data = await load(suiteFile, 'json'); @@ -221,7 +253,7 @@ async function fetchTestLog(suiteFile, testIndex, line) { } showTitle('Test:', name); showText(logtext); - setHL(line, true); + setHL(startLine, endLine, true); } async function load(url, dataType) { diff --git a/hivesim/hive.go b/hivesim/hive.go index f7a36005fc..ee4cb4f53e 100644 --- a/hivesim/hive.go +++ b/hivesim/hive.go @@ -207,6 +207,68 @@ func (sim *Simulation) StartClientWithOptions(testSuite SuiteID, test TestID, cl return resp.ID, ip, nil } +// StartSharedClient starts a new node as a shared client at the suite level. +// The client persists for the duration of the suite and can be used by multiple tests. +// Returns container id and ip. +func (sim *Simulation) StartSharedClient(testSuite SuiteID, clientType string, options ...StartOption) (string, net.IP, error) { + if sim.docs != nil { + return "", nil, errors.New("StartSharedClient is not supported in docs mode") + } + var ( + url = fmt.Sprintf("%s/testsuite/%d/node", sim.url, testSuite) + resp simapi.StartNodeResponse + ) + + setup := &clientSetup{ + files: make(map[string]func() (io.ReadCloser, error)), + config: simapi.NodeConfig{ + Client: clientType, + Environment: make(map[string]string), + }, + } + for _, opt := range options { + opt.apply(setup) + } + + err := setup.postWithFiles(url, &resp) + if err != nil { + return "", nil, err + } + ip := net.ParseIP(resp.IP) + if ip == nil { + return resp.ID, nil, fmt.Errorf("no IP address returned") + } + return resp.ID, ip, nil +} + +// ExecSharedClient runs a command in a shared client container. +func (sim *Simulation) ExecSharedClient(testSuite SuiteID, clientID string, cmd []string) (*ExecInfo, error) { + if sim.docs != nil { + return nil, errors.New("ExecSharedClient is not supported in docs mode") + } + var ( + url = fmt.Sprintf("%s/testsuite/%d/node/%s/exec", sim.url, testSuite, clientID) + req = &simapi.ExecRequest{Command: cmd} + resp *ExecInfo + ) + err := post(url, req, &resp) + return resp, err +} + +// RegisterSharedNode registers a shared client with a test. +func (sim *Simulation) RegisterSharedNode(testSuite SuiteID, test TestID, clientID string) error { + if sim.docs != nil { + return errors.New("RegisterSharedNode is not supported in docs mode") + } + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/testsuite/%d/node/%s/test/%d", sim.url, testSuite, clientID, test), nil) + if err != nil { + return err + } + _, err = http.DefaultClient.Do(req) + return err +} + // StopClient signals to the host that the node is no longer required. func (sim *Simulation) StopClient(testSuite SuiteID, test TestID, nodeid string) error { if sim.docs != nil { diff --git a/hivesim/shared_client_test.go b/hivesim/shared_client_test.go new file mode 100644 index 0000000000..20e2c086e4 --- /dev/null +++ b/hivesim/shared_client_test.go @@ -0,0 +1,164 @@ +package hivesim + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/hive/internal/fakes" + "github.com/ethereum/hive/internal/libhive" + "github.com/ethereum/hive/internal/simapi" +) + +// Tests shared client functionality by mocking server responses. +func TestStartSharedClient(t *testing.T) { + // This test creates a test HTTP server that mocks the simulation API. + // It responds to just the calls we need for this test, ignoring others. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/testsuite": + // StartSuite + json.NewEncoder(w).Encode(0) // Return suite ID + case "/testsuite/0/node": + // StartSharedClient + json.NewEncoder(w).Encode(simapi.StartNodeResponse{ + ID: "container1", + IP: "192.0.2.1", + }) + case "/testsuite/0/node/container1": + // GetSharedClientInfo + json.NewEncoder(w).Encode(simapi.NodeResponse{ + ID: "container1", + Name: "client-1", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + sim := NewAt(srv.URL) + + // Start a test suite + suiteID, err := sim.StartSuite(&simapi.TestRequest{Name: "shared-client-test-suite"}, "Testing shared clients") + if err != nil { + t.Fatal("can't start suite:", err) + } + + // Start a shared client + containerID, ip, err := sim.StartSharedClient(suiteID, "client-1", Params(map[string]string{ + "HIVE_PARAM": "value", + })) + if err != nil { + t.Fatal("can't start shared client:", err) + } + + if containerID != "container1" { + t.Errorf("wrong container ID: got %q, want %q", containerID, "container1") + } + expected := net.ParseIP("192.0.2.1") + if !ip.Equal(expected) { + t.Errorf("wrong IP returned: got %v, want %v", ip, expected) + } +} + +// Tests suites that use shared clients. +func TestSharedClientSuite(t *testing.T) { + var startedContainers int + + tm, srv := newFakeAPI(&fakes.BackendHooks{ + StartContainer: func(image, containerID string, opt libhive.ContainerOptions) (*libhive.ContainerInfo, error) { + startedContainers++ + return &libhive.ContainerInfo{ + ID: containerID, + IP: "192.0.2.1", + }, nil + }, + }) + defer srv.Close() + defer tm.Terminate() + + sim := NewAt(srv.URL) + + suiteWithSharedClients := Suite{ + Name: "shared-client-suite", + Description: "Testing shared client registration", + } + suiteWithSharedClients.SharedClientsOptions = func(clientDefinition *ClientDefinition) []StartOption { + return []StartOption{ + Params(map[string]string{ + "PARAM1": "value1", + }), + } + } + + sharedClientContainerID := "" + suiteWithSharedClients.Add(TestSpec{ + Name: "test-using-shared-client", + Run: func(t *T) { + sharedClientIDs := t.GetSharedClientIDs() + if len(sharedClientIDs) != 1 { + t.Fatal("wrong number of shared clients:", len(sharedClientIDs)) + } + sharedClientContainerID = sharedClientIDs[0] + client := t.GetSharedClient(sharedClientContainerID) + if client == nil { + t.Fatal("shared client not found") + } + + if client.Type != "client-1" { + t.Errorf("wrong client type: got %q, want %q", client.Type, "client-1") + } + if !client.IsShared { + t.Error("IsShared flag not set on client") + } + }, + }) + suiteWithSharedClients.Add(TestSpec{ + Name: "another-test-using-shared-client", + Run: func(t *T) { + sharedClientIDs := t.GetSharedClientIDs() + if len(sharedClientIDs) != 1 { + t.Fatal("wrong number of shared clients:", len(sharedClientIDs)) + } + if sharedClientIDs[0] != sharedClientContainerID { + t.Fatal("wrong shared client container ID:", sharedClientIDs[0], "!=", sharedClientContainerID) + } + client := t.GetSharedClient(sharedClientContainerID) + if client == nil { + t.Fatal("shared client not found") + } + + if client.Type != "client-1" { + t.Errorf("wrong client type: got %q, want %q", client.Type, "client-1") + } + if !client.IsShared { + t.Error("IsShared flag not set on client") + } + }, + }) + + err := RunSuite(sim, suiteWithSharedClients) + if err != nil { + t.Fatal("suite run failed:", err) + } + + if startedContainers == 0 { + t.Error("no containers were started") + } + + tm.Terminate() + results := tm.Results() + removeTimestamps(results) + + if len(results) == 0 { + t.Fatal("no test results") + } + + suiteResult := results[0] + if suiteResult.ClientInfo == nil || len(suiteResult.ClientInfo) == 0 { + t.Error("no shared clients in test results") + } +} diff --git a/hivesim/testapi.go b/hivesim/testapi.go index 8fa0dda97c..2872ce8e76 100644 --- a/hivesim/testapi.go +++ b/hivesim/testapi.go @@ -2,6 +2,7 @@ package hivesim import ( "context" + "errors" "fmt" "net" "os" @@ -21,6 +22,12 @@ type Suite struct { Category string // Category of the test suite [Optional] Description string // Description of the test suite (if empty, suite won't appear in documentation) [Optional] Tests []AnyTest + + // Function that, given the passed client definition, returns: + // - a list of options to start a shared client + // - nil if the given client type/role is not meant to be started + SharedClientsOptions func(clientDefinition *ClientDefinition) []StartOption + sharedClients map[string]Client } func (s *Suite) request() *simapi.TestRequest { @@ -77,11 +84,39 @@ func RunSuite(host *Simulation, suite Suite) error { } defer host.EndSuite(suiteID) + // Start shared clients + if suite.SharedClientsOptions != nil { + suite.sharedClients = map[string]Client{} + clients, err := host.ClientTypes() + if err != nil { + return err + } + for _, clientDef := range clients { + options := suite.SharedClientsOptions(clientDef) + if options == nil { + continue + } + container, ip, err := host.StartSharedClient(suiteID, clientDef.Name, options...) + if err != nil { + return err + } + suite.sharedClients[container] = Client{ + Type: clientDef.Name, + Container: container, + IP: ip, + IsShared: true, + mu: &sync.Mutex{}, + } + } + } + + // Run all tests in the suite for _, test := range suite.Tests { if err := test.runTest(host, suiteID, &suite); err != nil { return err } } + return nil } @@ -159,8 +194,9 @@ type Client struct { Type string Container string IP net.IP + IsShared bool - mu sync.Mutex + mu *sync.Mutex rpc *rpc.Client enginerpc *rpc.Client test *T @@ -202,16 +238,25 @@ func (c *Client) EngineAPI() *rpc.Client { // Exec runs a script in the client container. func (c *Client) Exec(command ...string) (*ExecInfo, error) { + if c.IsShared { + return c.test.Sim.ExecSharedClient(c.test.SuiteID, c.Container, command) + } return c.test.Sim.ClientExec(c.test.SuiteID, c.test.TestID, c.Container, command) } // Pauses the client container. func (c *Client) Pause() error { + if c.IsShared { + return errors.New("cannot pause shared client") + } return c.test.Sim.PauseClient(c.test.SuiteID, c.test.TestID, c.Container) } // Unpauses the client container. func (c *Client) Unpause() error { + if c.IsShared { + return errors.New("cannot unpause shared client") + } return c.test.Sim.UnpauseClient(c.test.SuiteID, c.test.TestID, c.Container) } @@ -235,7 +280,64 @@ func (t *T) StartClient(clientType string, option ...StartOption) *Client { if err != nil { t.Fatalf("can't launch node (type %s): %v", clientType, err) } - return &Client{Type: clientType, Container: container, IP: ip, test: t} + + return &Client{ + Type: clientType, + Container: container, + IP: ip, + test: t, + IsShared: false, + mu: &sync.Mutex{}, + } +} + +func (t *T) GetSharedClientIDs() []string { + if t.suite == nil || t.suite.sharedClients == nil { + return []string{} + } + ids := make([]string, 0, len(t.suite.sharedClients)) + for id := range t.suite.sharedClients { + ids = append(ids, id) + } + return ids +} + +// GetSharedClient retrieves a shared client by ID and prepares it for use in this test. +// The client can be used like a normal Client object, but maintains its state across tests. +// Returns nil if the client doesn't exist. +func (t *T) GetSharedClient(clientID string) *Client { + if t.suite == nil || t.suite.sharedClients == nil { + t.Logf("No shared clients available in this suite") + return nil + } + + sharedClient, exists := t.suite.sharedClients[clientID] + if !exists { + t.Logf("Shared client %q not found", clientID) + return nil + } + + // Store the test context in the client so it can be used for this test + // Create a new Client instance that points to the same container + client := &Client{ + Type: sharedClient.Type, + Container: sharedClient.Container, + IP: sharedClient.IP, + IsShared: true, + + mu: sharedClient.mu, + rpc: sharedClient.rpc, + enginerpc: sharedClient.enginerpc, + test: t, + } + + if err := t.Sim.RegisterSharedNode(t.SuiteID, t.TestID, clientID); err != nil { + t.Logf("Warning: Failed to register shared client %s with test: %v", clientID, err) + } else { + t.Logf("Successfully registered shared client %s with test %d", clientID, t.TestID) + } + + return client } // RunClient runs the given client test against a single client type. diff --git a/hivesim/testapi_test.go b/hivesim/testapi_test.go index e076e6ae75..b2066c9616 100644 --- a/hivesim/testapi_test.go +++ b/hivesim/testapi_test.go @@ -69,6 +69,35 @@ func TestSuiteReporting(t *testing.T) { }, }, } + // Update expected results to match new fields + wantResults = map[libhive.TestSuiteID]*libhive.TestSuite{ + 0: { + ID: 0, + Name: suite.Name, + Description: suite.Description, + ClientVersions: make(map[string]string), + TestCases: map[libhive.TestID]*libhive.TestCase{ + 1: { + Name: "passing test", + Description: "this test passes", + SummaryResult: libhive.TestResult{ + Pass: true, + Details: "message from the passing test\n", + }, + }, + 2: { + Name: "failing test", + Description: "this test fails", + SummaryResult: libhive.TestResult{ + Pass: false, + Details: "message from the failing test\n", + }, + }, + }, + ClientInfo: nil, // Add this field to expected results + }, + } + if !reflect.DeepEqual(results, wantResults) { t.Fatal("wrong results reported:", spew.Sdump(results)) } diff --git a/internal/libhive/api.go b/internal/libhive/api.go index bf90da9356..f96cb28048 100644 --- a/internal/libhive/api.go +++ b/internal/libhive/api.go @@ -34,17 +34,30 @@ func newSimulationAPI(b ContainerBackend, env SimEnv, tm *TestManager, hive Hive router := mux.NewRouter() router.HandleFunc("/hive", api.getHiveInfo).Methods("GET") router.HandleFunc("/clients", api.getClientTypes).Methods("GET") - router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/exec", api.execInClient).Methods("POST") - router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}", api.getNodeStatus).Methods("GET") + + // Test suite and client routes + router.HandleFunc("/testsuite", api.startSuite).Methods("POST") + router.HandleFunc("/testsuite/{suite}", api.endSuite).Methods("DELETE") + router.HandleFunc("/testsuite/{suite}/test", api.startTest).Methods("POST") + // post because the delete http verb does not always support a message body + router.HandleFunc("/testsuite/{suite}/test/{test}", api.endTest).Methods("POST") + + // Shared client routes + router.HandleFunc("/testsuite/{suite}/node", api.startClient).Methods("POST") + router.HandleFunc("/testsuite/{suite}/node/{node}", api.getNodeStatus).Methods("GET") + router.HandleFunc("/testsuite/{suite}/node/{node}/exec", api.execInClient).Methods("POST") + router.HandleFunc("/testsuite/{suite}/node/{node}", api.stopClient).Methods("DELETE") + router.HandleFunc("/testsuite/{suite}/node/{node}/test/{test}", api.registerSharedClient).Methods("POST") + + // Regular client routes router.HandleFunc("/testsuite/{suite}/test/{test}/node", api.startClient).Methods("POST") + router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}", api.getNodeStatus).Methods("GET") + router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/exec", api.execInClient).Methods("POST") router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}", api.stopClient).Methods("DELETE") router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/pause", api.pauseClient).Methods("POST") router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/pause", api.unpauseClient).Methods("DELETE") - router.HandleFunc("/testsuite/{suite}/test", api.startTest).Methods("POST") - // post because the delete http verb does not always support a message body - router.HandleFunc("/testsuite/{suite}/test/{test}", api.endTest).Methods("POST") - router.HandleFunc("/testsuite", api.startSuite).Methods("POST") - router.HandleFunc("/testsuite/{suite}", api.endSuite).Methods("DELETE") + + // Network routes router.HandleFunc("/testsuite/{suite}/network/{network}", api.networkCreate).Methods("POST") router.HandleFunc("/testsuite/{suite}/network/{network}", api.networkRemove).Methods("DELETE") router.HandleFunc("/testsuite/{suite}/network/{network}/{node}", api.networkIPGet).Methods("GET") @@ -166,7 +179,7 @@ func (api *simAPI) endTest(w http.ResponseWriter, r *http.Request) { // startClient starts a client container. func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { - suiteID, testID, err := api.requestSuiteAndTest(r) + suiteID, testID, err := api.requestSuiteAndOptionalTest(r) if err != nil { serveError(w, err, http.StatusBadRequest) return @@ -250,7 +263,9 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { labels := NewBaseLabels(api.tm.hiveInstanceID, api.tm.hiveVersion) labels[LabelHiveType] = ContainerTypeClient labels[LabelHiveTestSuite] = suiteID.String() - labels[LabelHiveTestCase] = testID.String() + if testID != nil { + labels[LabelHiveTestCase] = testID.String() + } labels[LabelHiveClientName] = clientDef.Name labels[LabelHiveClientImage] = clientDef.Image @@ -261,7 +276,7 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { options := ContainerOptions{Env: env, Files: files, Labels: labels, Name: containerName} containerID, err := api.backend.CreateContainer(ctx, clientDef.Image, options) if err != nil { - slog.Error("API: client container create failed", "client", clientDef.Name, "error", err) + slog.Error("API: client container create failed", "client", clientDef.Name, "error", err, "containerName", containerName) err := fmt.Errorf("client container create failed (%v)", err) serveError(w, err, http.StatusInternalServerError) return @@ -304,6 +319,9 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { LogFile: logPath, wait: info.Wait, } + if testID == nil { + clientInfo.IsShared = true + } // Add client version to the test suite. api.tm.testSuiteMutex.Lock() @@ -314,7 +332,7 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { // Register the node. This should always be done, even if starting the container // failed, to ensure that the failed client log is associated with the test. - api.tm.RegisterNode(testID, info.ID, clientInfo) + api.tm.RegisterNode(suiteID, testID, info.ID, clientInfo) } if err != nil { slog.Error("API: could not start client", "client", clientDef.Name, "container", containerID[:8], "error", err) @@ -324,10 +342,45 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { } // It's started. - slog.Info("API: client "+clientDef.Name+" started", "suite", suiteID, "test", testID, "container", containerID[:8]) + if testID == nil { + slog.Info("API: shared client "+clientDef.Name+" started", "suite", suiteID, "container", containerID[:8], "containerName", containerName) + } else { + slog.Info("API: client "+clientDef.Name+" started", "suite", suiteID, "test", testID, "container", containerID[:8], "containerName", containerName) + } serveJSON(w, &simapi.StartNodeResponse{ID: info.ID, IP: info.IP}) } +// registerSharedClient registers a shared client in a test. +func (api *simAPI) registerSharedClient(w http.ResponseWriter, r *http.Request) { + suiteID, testID, err := api.requestSuiteAndTest(r) + if err != nil { + serveError(w, err, http.StatusBadRequest) + return + } + + node := mux.Vars(r)["node"] + sharedClient, err := api.tm.GetNodeInfo(suiteID, nil, node) + if err != nil { + slog.Error("API: can't find shared client", "node", node, "error", err) + serveError(w, err, http.StatusNotFound) + return + } + if !sharedClient.IsShared { + slog.Error("API: shared client is not shared", "node", node) + serveError(w, err, http.StatusBadRequest) + return + } + + // Register the node with the test + if err := api.tm.RegisterSharedClient(suiteID, testID, node); err != nil { + slog.Error("API: failed to register shared client", "node", node, "error", err) + serveError(w, err, http.StatusInternalServerError) + return + } + + serveOK(w) +} + // clientLogFilePaths determines the log file path of a client container. // Note that jsonPath gets written to the result JSON and always uses '/' as the separator. // The filePath is passed to the docker backend and uses the platform separator. @@ -363,14 +416,14 @@ func (api *simAPI) checkClientNetworks(req *simapi.NodeConfig, suiteID TestSuite // stopClient terminates a client container. func (api *simAPI) stopClient(w http.ResponseWriter, r *http.Request) { - _, testID, err := api.requestSuiteAndTest(r) + suiteID, testID, err := api.requestSuiteAndOptionalTest(r) if err != nil { serveError(w, err, http.StatusBadRequest) return } node := mux.Vars(r)["node"] - err = api.tm.StopNode(testID, node) + err = api.tm.StopNode(suiteID, testID, node) switch { case err == ErrNoSuchNode: serveError(w, err, http.StatusNotFound) @@ -423,7 +476,7 @@ func (api *simAPI) unpauseClient(w http.ResponseWriter, r *http.Request) { // getNodeStatus returns the status of a client container. func (api *simAPI) getNodeStatus(w http.ResponseWriter, r *http.Request) { - suiteID, testID, err := api.requestSuiteAndTest(r) + suiteID, testID, err := api.requestSuiteAndOptionalTest(r) if err != nil { serveError(w, err, http.StatusBadRequest) return @@ -441,7 +494,7 @@ func (api *simAPI) getNodeStatus(w http.ResponseWriter, r *http.Request) { } func (api *simAPI) execInClient(w http.ResponseWriter, r *http.Request) { - suiteID, testID, err := api.requestSuiteAndTest(r) + suiteID, testID, err := api.requestSuiteAndOptionalTest(r) if err != nil { serveError(w, err, http.StatusBadRequest) return @@ -626,6 +679,28 @@ func (api *simAPI) requestSuiteAndTest(r *http.Request) (TestSuiteID, TestID, er return suiteID, testID, err } +// requestSuiteAndOptionalTest returns the suite ID and test ID from the request body. +func (api *simAPI) requestSuiteAndOptionalTest(r *http.Request) (TestSuiteID, *TestID, error) { + suiteID, err := api.requestSuite(r) + if err != nil { + return 0, nil, err + } + var testID *TestID + testString, ok := mux.Vars(r)["test"] + if ok { + testCase, err := strconv.Atoi(testString) + if err != nil { + return 0, nil, fmt.Errorf("invalid test case id %q", testString) + } + testCaseID := TestID(testCase) + if _, running := api.tm.IsTestRunning(testCaseID); !running { + return 0, nil, fmt.Errorf("test case %d is not running", testCaseID) + } + testID = &testCaseID + } + return suiteID, testID, nil +} + func serveJSON(w http.ResponseWriter, value interface{}) { resp, err := json.Marshal(value) if err != nil { diff --git a/internal/libhive/data.go b/internal/libhive/data.go index 824fcd3ae0..537997df05 100644 --- a/internal/libhive/data.go +++ b/internal/libhive/data.go @@ -9,15 +9,15 @@ import ( // Docker label keys used by Hive const ( - LabelHiveInstance = "hive.instance" // Unique Hive instance ID - LabelHiveVersion = "hive.version" // Hive version/commit - LabelHiveType = "hive.type" // container type: client|simulator|proxy - LabelHiveTestSuite = "hive.test.suite" // test suite ID - LabelHiveTestCase = "hive.test.case" // test case ID - LabelHiveClientName = "hive.client.name" // client name (go-ethereum, etc) - LabelHiveClientImage = "hive.client.image" // Docker image name - LabelHiveCreated = "hive.created" // RFC3339 timestamp - LabelHiveSimulator = "hive.simulator" // simulator name + LabelHiveInstance = "hive.instance" // Unique Hive instance ID + LabelHiveVersion = "hive.version" // Hive version/commit + LabelHiveType = "hive.type" // container type: client|simulator|proxy + LabelHiveTestSuite = "hive.test.suite" // test suite ID + LabelHiveTestCase = "hive.test.case" // test case ID + LabelHiveClientName = "hive.client.name" // client name (go-ethereum, etc) + LabelHiveClientImage = "hive.client.image" // Docker image name + LabelHiveCreated = "hive.created" // RFC3339 timestamp + LabelHiveSimulator = "hive.simulator" // simulator name ) // Container types @@ -47,7 +47,7 @@ func SanitizeContainerNameComponent(s string) string { if s == "" { return s } - + // Replace invalid characters with dashes sanitized := "" for i, r := range s { @@ -61,10 +61,10 @@ func SanitizeContainerNameComponent(s string) string { sanitized += "-" } } - + // Ensure first character is alphanumeric - if len(sanitized) > 0 && !((sanitized[0] >= 'a' && sanitized[0] <= 'z') || - (sanitized[0] >= 'A' && sanitized[0] <= 'Z') || + if len(sanitized) > 0 && !((sanitized[0] >= 'a' && sanitized[0] <= 'z') || + (sanitized[0] >= 'A' && sanitized[0] <= 'Z') || (sanitized[0] >= '0' && sanitized[0] <= '9')) { if len(sanitized) > 1 { sanitized = sanitized[1:] @@ -72,13 +72,13 @@ func SanitizeContainerNameComponent(s string) string { sanitized = "container" } } - + return sanitized } // GenerateContainerName generates a Hive-prefixed container name func GenerateContainerName(containerType, identifier string) string { - timestamp := time.Now().Format("20060102-150405") + timestamp := time.Now().Format("20060102-150405.0000") sanitizedType := SanitizeContainerNameComponent(containerType) if identifier != "" { sanitizedIdentifier := SanitizeContainerNameComponent(identifier) @@ -88,8 +88,11 @@ func GenerateContainerName(containerType, identifier string) string { } // GenerateClientContainerName generates a name for client containers -func GenerateClientContainerName(clientName string, suiteID TestSuiteID, testID TestID) string { - identifier := fmt.Sprintf("%s-s%s-t%s", clientName, suiteID.String(), testID.String()) +func GenerateClientContainerName(clientName string, suiteID TestSuiteID, testID *TestID) string { + identifier := fmt.Sprintf("%s-s%s", clientName, suiteID.String()) + if testID != nil { + identifier += fmt.Sprintf("-t%s", testID.String()) + } return GenerateContainerName("client", identifier) } @@ -126,6 +129,9 @@ type TestSuite struct { RunMetadata *RunMetadata `json:"runMetadata,omitempty"` // Enhanced run metadata TestCases map[TestID]*TestCase `json:"testCases"` + // Shared client support + ClientInfo map[string]*ClientInfo `json:"clientInfo,omitempty"` // Map of shared clients available to all tests in this suite + SimulatorLog string `json:"simLog"` // path to simulator log-file simulator. (may be shared with multiple suites) TestDetailsLog string `json:"testDetailsLog"` // the test details output file @@ -154,6 +160,15 @@ type TestResult struct { LogOffsets *TestLogOffsets `json:"log,omitempty"` } +// ClientLogSegment represents a segment of a client log file +type ClientLogSegment struct { + Start int64 `json:"start"` // Starting byte offset in log file + End int64 `json:"end"` // Ending byte offset in log file + StartLine int `json:"startLine"` // Starting line number + EndLine int `json:"endLine"` // Ending line number + ClientID string `json:"clientId"` // ID of the client +} + type TestLogOffsets struct { Begin int64 `json:"begin"` End int64 `json:"end"` @@ -167,6 +182,11 @@ type ClientInfo struct { InstantiatedAt time.Time `json:"instantiatedAt"` LogFile string `json:"logFile"` //Absolute path to the logfile. + // Fields for shared client support + IsShared bool `json:"isShared"` // Indicates if this client is shared across tests + LogStartByte *int64 `json:"logStartByte,omitempty"` // Start byte in log file for shared clients + LogEndByte *int64 `json:"logEndByte,omitempty"` // End byte in log file for shared clients + wait func() } diff --git a/internal/libhive/data_test.go b/internal/libhive/data_test.go index 476c6695cd..7749427485 100644 --- a/internal/libhive/data_test.go +++ b/internal/libhive/data_test.go @@ -8,7 +8,7 @@ import ( func TestGenerateHiveInstanceID(t *testing.T) { id1 := GenerateHiveInstanceID() - + // Sleep briefly to ensure different timestamp time.Sleep(time.Millisecond) id2 := GenerateHiveInstanceID() @@ -119,8 +119,9 @@ func TestGenerateContainerName(t *testing.T) { } func TestGenerateClientContainerName(t *testing.T) { - name := GenerateClientContainerName("go-ethereum", TestSuiteID(1), TestID(2)) - + testID := TestID(2) + name := GenerateClientContainerName("go-ethereum", TestSuiteID(1), &testID) + if len(name) < 5 || name[:5] != "hive-" { t.Errorf("Client container name should start with 'hive-', got: %s", name) } @@ -136,7 +137,7 @@ func TestGenerateClientContainerName(t *testing.T) { func TestGenerateSimulatorContainerName(t *testing.T) { name := GenerateSimulatorContainerName("devp2p") - + if len(name) < 5 || name[:5] != "hive-" { t.Errorf("Simulator container name should start with 'hive-', got: %s", name) } @@ -151,7 +152,7 @@ func TestGenerateSimulatorContainerName(t *testing.T) { func TestGenerateProxyContainerName(t *testing.T) { name := GenerateProxyContainerName() - + if len(name) < 5 || name[:5] != "hive-" { t.Errorf("Proxy container name should start with 'hive-', got: %s", name) } @@ -191,12 +192,12 @@ func TestSanitizeContainerNameComponent(t *testing.T) { func TestSanitizedContainerNames(t *testing.T) { // Test that names with slashes get properly sanitized name := GenerateSimulatorContainerName("ethereum/rpc-compat") - + if strings.Contains(name, "/") { t.Errorf("Container name should not contain '/', got: %s", name) } - + if !strings.Contains(name, "ethereum-rpc-compat") { t.Errorf("Container name should contain sanitized simulator name, got: %s", name) } -} \ No newline at end of file +} diff --git a/internal/libhive/testmanager.go b/internal/libhive/testmanager.go index 3de168a096..ec3427cbc8 100644 --- a/internal/libhive/testmanager.go +++ b/internal/libhive/testmanager.go @@ -15,6 +15,7 @@ import ( var ( ErrNoSuchNode = errors.New("no such node") + ErrSharedClientNotRunning = errors.New("shared client not running") ErrNoSuchTestSuite = errors.New("no such test suite") ErrNoSuchTestCase = errors.New("no such test case") ErrMissingClientType = errors.New("missing client type") @@ -105,14 +106,14 @@ func filterClientDesignators(clients []ClientDesignator) []ClientDesignator { DockerfileExt: client.DockerfileExt, BuildArgs: make(map[string]string), } - + // Filter build args for key, value := range client.BuildArgs { if !excludedBuildArgs[key] { filteredClient.BuildArgs[key] = value } } - + filtered[i] = filteredClient } return filtered @@ -122,10 +123,10 @@ func NewTestManager(config SimEnv, b ContainerBackend, clients []*ClientDefiniti if hiveInfo.Commit == "" && hiveInfo.Date == "" { hiveInfo.Commit, hiveInfo.Date = hiveVersion() } - + // Filter sensitive build args from HiveInfo.ClientFile hiveInfo.ClientFile = filterClientDesignators(hiveInfo.ClientFile) - + return &TestManager{ clientDefs: clients, config: config, @@ -212,19 +213,38 @@ func (manager *TestManager) Terminate() error { } // GetNodeInfo gets some info on a client belonging to some test -func (manager *TestManager) GetNodeInfo(testSuite TestSuiteID, test TestID, nodeID string) (*ClientInfo, error) { - manager.testCaseMutex.RLock() - defer manager.testCaseMutex.RUnlock() +func (manager *TestManager) GetNodeInfo(testSuite TestSuiteID, test *TestID, nodeID string) (*ClientInfo, error) { + if test != nil { + manager.testCaseMutex.RLock() + defer manager.testCaseMutex.RUnlock() + + testCase, ok := manager.runningTestCases[*test] + if !ok { + return nil, ErrNoSuchTestCase + } + nodeInfo, ok := testCase.ClientInfo[nodeID] + if !ok { + return nil, ErrNoSuchNode + } + return nodeInfo, nil + } else { + manager.testSuiteMutex.RLock() + defer manager.testSuiteMutex.RUnlock() - testCase, ok := manager.runningTestCases[test] - if !ok { - return nil, ErrNoSuchTestCase - } - nodeInfo, ok := testCase.ClientInfo[nodeID] - if !ok { - return nil, ErrNoSuchNode + testSuite, ok := manager.runningTestSuites[testSuite] + if !ok { + return nil, ErrNoSuchTestSuite + } + + if testSuite.ClientInfo == nil { + return nil, ErrNoSuchNode + } + client, ok := testSuite.ClientInfo[nodeID] + if !ok { + return nil, ErrNoSuchNode + } + return client, nil } - return nodeInfo, nil } // CreateNetwork creates a docker network with the given network name. @@ -397,29 +417,44 @@ func (manager *TestManager) doEndSuite(testSuite TestSuiteID) error { if suite.testDetailsFile != nil { suite.testDetailsFile.Close() } - + // Create comprehensive run metadata runMetadata := &RunMetadata{ HiveCommand: manager.hiveInfo.Command, HiveVersion: GetHiveVersion(), } - + // Add client configuration if available if manager.hiveInfo.ClientFilePath != "" && len(manager.hiveInfo.ClientFile) > 0 { // Convert existing ClientFile data to consistent format for storage clientConfigContent := map[string]interface{}{ "clients": manager.hiveInfo.ClientFile, } - + runMetadata.ClientConfig = &ClientConfigInfo{ FilePath: manager.hiveInfo.ClientFilePath, Content: clientConfigContent, } } - + // Attach metadata to suite suite.RunMetadata = runMetadata - + + // Clean up any shared clients for this suite. + if suite.ClientInfo != nil { + for nodeID, clientInfo := range suite.ClientInfo { + // Stop the container if it's still running. + if clientInfo.wait != nil { + slog.Info("cleaning up shared client", "suite", testSuite, "client", clientInfo.Name, "container", nodeID[:8]) + if err := manager.backend.DeleteContainer(clientInfo.ID); err != nil { + slog.Error("could not stop shared client", "suite", testSuite, "container", nodeID[:8], "err", err) + } + clientInfo.wait() + clientInfo.wait = nil + } + } + } + // Write the result. if manager.config.LogDir != "" { err := writeSuiteFile(suite, manager.config.LogDir) @@ -531,9 +566,38 @@ func (manager *TestManager) EndTest(suiteID TestSuiteID, testID TestID, result * result.Details = "" result.LogOffsets = offsets } + + // Log the number of clients in the test case for debugging + if len(testCase.ClientInfo) > 0 { + slog.Debug("Processing client logs", + "testID", testID, + "clientCount", len(testCase.ClientInfo), + "clientTypes", fmt.Sprintf("%v", func() []string { + types := make([]string, 0, len(testCase.ClientInfo)) + for _, ci := range testCase.ClientInfo { + types = append(types, ci.Name) + } + return types + }())) + } else { + slog.Debug("No clients registered with test", "testID", testID) + } + + for _, clientInfo := range testCase.ClientInfo { + if clientInfo.IsShared { + // Get current log position + logEndByte, err := manager.getClientCurrentByteCount(clientInfo) + if err != nil { + slog.Error("could not get client log position", "err", err) + continue + } + clientInfo.LogEndByte = &logEndByte + } + } + testCase.SummaryResult = *result - // Stop running clients. + // Stop running clients that are not shared. for _, v := range testCase.ClientInfo { if v.wait != nil { manager.backend.DeleteContainer(v.ID) @@ -571,11 +635,60 @@ func (manager *TestManager) writeTestDetails(suite *TestSuite, testCase *TestCas return &offsets } -// RegisterNode is used by test suite hosts to register the creation of a node in the context of a test -func (manager *TestManager) RegisterNode(testID TestID, nodeID string, nodeInfo *ClientInfo) error { - manager.testCaseMutex.Lock() - defer manager.testCaseMutex.Unlock() +// RegisterNode is used by test suite hosts to register the creation of a node. +// If testID is nil, the node is a shared client. +func (manager *TestManager) RegisterNode(suiteID TestSuiteID, testID *TestID, nodeID string, nodeInfo *ClientInfo) error { + if testID != nil { + manager.testCaseMutex.Lock() + defer manager.testCaseMutex.Unlock() + // Check if the test case is running + testCase, ok := manager.runningTestCases[*testID] + if !ok { + return ErrNoSuchTestCase + } + if testCase.ClientInfo == nil { + testCase.ClientInfo = make(map[string]*ClientInfo) + } + + testCase.ClientInfo[nodeID] = nodeInfo + return nil + } else { + manager.testSuiteMutex.Lock() + defer manager.testSuiteMutex.Unlock() + + // Check if the test suite is running + testSuite, ok := manager.runningTestSuites[suiteID] + if !ok { + return ErrNoSuchTestSuite + } + + // Initialize shared clients map if it doesn't exist + if testSuite.ClientInfo == nil { + testSuite.ClientInfo = make(map[string]*ClientInfo) + } + + testSuite.ClientInfo[nodeID] = nodeInfo + return nil + } +} + +// RegisterSharedClient registers an already started shared client in a specific test +func (manager *TestManager) RegisterSharedClient(suiteID TestSuiteID, testID TestID, nodeID string) error { + suite, ok := manager.runningTestSuites[suiteID] + if !ok { + return ErrNoSuchTestSuite + } + if suite.ClientInfo == nil { + return ErrNoSuchNode + } + nodeInfo, ok := suite.ClientInfo[nodeID] + if !ok { + return ErrNoSuchNode + } + if nodeInfo.wait == nil { + return ErrSharedClientNotRunning + } // Check if the test case is running testCase, ok := manager.runningTestCases[testID] if !ok { @@ -584,22 +697,66 @@ func (manager *TestManager) RegisterNode(testID TestID, nodeID string, nodeInfo if testCase.ClientInfo == nil { testCase.ClientInfo = make(map[string]*ClientInfo) } - testCase.ClientInfo[nodeID] = nodeInfo + logStartByte, err := manager.getClientCurrentByteCount(nodeInfo) + if err != nil { + return err + } + logStartByte += 1 + + // Create a new client info object with the shared client info, + // without the wait function because we don't want to stop the + // container at the end of the test + nodeInfoTestCopy := ClientInfo{ + ID: nodeInfo.ID, + IP: nodeInfo.IP, + Name: nodeInfo.Name, + InstantiatedAt: nodeInfo.InstantiatedAt, + LogFile: nodeInfo.LogFile, + IsShared: nodeInfo.IsShared, + LogStartByte: &logStartByte, + LogEndByte: nil, // TODO: get the end position from the log file when the test is finished + } + // TODO: We can optionally add a stopClient flag to the RegisterSharedClient function + // to stop the client at the end of the test, but we need to create a new wait function + // that calls and then clears the wait function from the original nodeInfo. + testCase.ClientInfo[nodeID] = &nodeInfoTestCopy + return nil } // StopNode stops a client container. -func (manager *TestManager) StopNode(testID TestID, nodeID string) error { - manager.testCaseMutex.Lock() - defer manager.testCaseMutex.Unlock() +func (manager *TestManager) StopNode(suiteID TestSuiteID, testID *TestID, nodeID string) error { + var nodeInfo *ClientInfo + if testID != nil { + manager.testCaseMutex.Lock() + defer manager.testCaseMutex.Unlock() + + testCase, ok := manager.runningTestCases[*testID] + if !ok { + return ErrNoSuchNode + } + nodeInfo, ok = testCase.ClientInfo[nodeID] + if !ok { + return ErrNoSuchNode + } - testCase, ok := manager.runningTestCases[testID] - if !ok { - return ErrNoSuchNode - } - nodeInfo, ok := testCase.ClientInfo[nodeID] - if !ok { - return ErrNoSuchNode + } else { + manager.testSuiteMutex.Lock() + defer manager.testSuiteMutex.Unlock() + + // Check if the test suite is running + testSuite, ok := manager.runningTestSuites[suiteID] + if !ok { + return ErrNoSuchTestSuite + } + + if testSuite.ClientInfo == nil { + return ErrNoSuchNode + } + nodeInfo, ok = testSuite.ClientInfo[nodeID] + if !ok { + return ErrNoSuchNode + } } // Stop the container. if nodeInfo.wait != nil { @@ -652,19 +809,35 @@ func (manager *TestManager) UnpauseNode(testID TestID, nodeID string) error { return nil } +// countLinesInFile counts the current number of lines in a file (1-based). +func (manager *TestManager) getClientCurrentByteCount(clientInfo *ClientInfo) (int64, error) { + // Ensure we have the full path to the log file + fullPath := clientInfo.LogFile + if !filepath.IsAbs(fullPath) { + fullPath = filepath.Join(manager.config.LogDir, clientInfo.LogFile) + } + slog.Debug("Opening log file", "path", fullPath) + + fileInfo, err := os.Stat(fullPath) + if err != nil { + return 0, err + } + + return fileInfo.Size(), nil +} + // writeSuiteFile writes the simulation result to the log directory. // List of build arguments to exclude from result JSON for security/privacy var excludedBuildArgs = map[string]bool{ - "GOPROXY": true, // Go proxy URLs may contain sensitive info - "GITHUB_TOKEN": true, // GitHub tokens - "ACCESS_TOKEN": true, // Generic access tokens - "API_KEY": true, // API keys - "PASSWORD": true, // Passwords - "SECRET": true, // Generic secrets - "TOKEN": true, // Generic tokens + "GOPROXY": true, // Go proxy URLs may contain sensitive info + "GITHUB_TOKEN": true, // GitHub tokens + "ACCESS_TOKEN": true, // Generic access tokens + "API_KEY": true, // API keys + "PASSWORD": true, // Passwords + "SECRET": true, // Generic secrets + "TOKEN": true, // Generic tokens } - func writeSuiteFile(s *TestSuite, logdir string) error { suiteData, err := json.Marshal(s) if err != nil { diff --git a/internal/simapi/simapi.go b/internal/simapi/simapi.go index 59a173f6fa..c81c7f1133 100644 --- a/internal/simapi/simapi.go +++ b/internal/simapi/simapi.go @@ -28,6 +28,14 @@ type NodeResponse struct { Name string `json:"name"` } +// NodeInfo contains information about a client node to register with a test. +type NodeInfo struct { + ID string `json:"id"` // Container ID + Name string `json:"name"` // Client name/type + IsShared bool `json:"isShared"` // Whether this is a shared client + SharedClientID string `json:"sharedClientId,omitempty"` // ID of the shared client in the suite +} + type ExecRequest struct { Command []string `json:"command"` } diff --git a/simulators/ethereum/eest/consume-enginex/Dockerfile b/simulators/ethereum/eest/consume-enginex/Dockerfile new file mode 100644 index 0000000000..0c77df02ea --- /dev/null +++ b/simulators/ethereum/eest/consume-enginex/Dockerfile @@ -0,0 +1,31 @@ +# Builds and runs the EEST (execution-spec-tests) consume engine simulator +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +## Default fixtures/git-ref +ARG fixtures=stable@latest +ENV FIXTURES=${fixtures} +ARG branch=main +ENV GIT_REF=${branch} + +## Clone and install EEST +RUN apt-get update && apt-get install -y git + +# Allow the user to specify a branch or commit to checkout +RUN git init execution-spec-tests && \ + cd execution-spec-tests && \ + git remote add origin https://github.com/ethereum/execution-spec-tests.git && \ + git fetch --depth 1 origin $GIT_REF && \ + git checkout FETCH_HEAD; + +WORKDIR /execution-spec-tests +RUN uv sync + +# Cache the fixtures. This is done to avoid re-downloading the fixtures every time +# the container starts. +# If newer version of the fixtures is needed, the image needs to be rebuilt. +# Use `--docker.nocache` flag to force rebuild. +RUN uv run consume cache --input "$FIXTURES" + +## Define `consume engine` entry point using the local fixtures +ENTRYPOINT uv run consume enginex -v --input "$FIXTURES" --dist=loadgroup --enginex-fcu-frequency=0 +