From c096ab3a60e947fed229c0cda72be972244ff325 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Wed, 6 May 2026 02:27:41 +0200 Subject: [PATCH 1/9] Add support for specifying `cardano-node` binary for each node independently --- .../src/Cardano/Node/Testnet/Paths.hs | 5 + cardano-testnet/src/Parsers/Cardano.hs | 72 ++++++++-- cardano-testnet/src/Testnet/Runtime.hs | 9 +- cardano-testnet/src/Testnet/Start/Cardano.hs | 128 ++++++++++++------ cardano-testnet/src/Testnet/Start/Types.hs | 11 +- .../files/golden/help.cli | 5 +- .../files/golden/help/cardano.cli | 7 +- .../files/golden/help/create-env.cli | 8 +- .../Cardano/Testnet/Test/Cli/KesPeriodInfo.hs | 2 +- .../Testnet/Test/Cli/LeadershipSchedule.hs | 8 +- .../Test/Gov/ProposeNewConstitutionSPO.hs | 6 +- .../Cardano/Testnet/Test/Node/Shutdown.hs | 2 +- 12 files changed, 192 insertions(+), 71 deletions(-) diff --git a/cardano-node/src/Cardano/Node/Testnet/Paths.hs b/cardano-node/src/Cardano/Node/Testnet/Paths.hs index 9c125ffcc05..82feb6f35eb 100644 --- a/cardano-node/src/Cardano/Node/Testnet/Paths.hs +++ b/cardano-node/src/Cardano/Node/Testnet/Paths.hs @@ -15,6 +15,7 @@ module Cardano.Node.Testnet.Paths , defaultSocketPath , defaultConfigFile , defaultPortFile + , defaultNodeEnvFile ) where import System.FilePath (()) @@ -62,3 +63,7 @@ defaultConfigFile = "configuration.yaml" -- | Relative path to a node's port file: @defaultNodeDataDir n "port"@ defaultPortFile :: Int -> FilePath defaultPortFile n = defaultNodeDataDir n "port" + +-- | Relative path to a node's env file: @defaultNodeDataDir n "env"@ +defaultNodeEnvFile :: Int -> FilePath +defaultNodeEnvFile n = defaultNodeDataDir n "env" diff --git a/cardano-testnet/src/Parsers/Cardano.hs b/cardano-testnet/src/Parsers/Cardano.hs index d93ec846982..e45e5a02a9d 100644 --- a/cardano-testnet/src/Parsers/Cardano.hs +++ b/cardano-testnet/src/Parsers/Cardano.hs @@ -13,6 +13,7 @@ import Cardano.Prelude (readMaybe) import Prelude import Control.Applicative (optional, (<|>)) +import Control.Monad (unless) import Data.Default.Class (def) import qualified Data.List as L import Data.List.NonEmpty (NonEmpty ((:|))) @@ -107,19 +108,17 @@ pKesSource = OA.flag UseKesKeyFile UseKesSocket pTestnetNodeOptions :: Parser TestnetNodeOptions pTestnetNodeOptions = - fmap (maybe cardanoDefaultTestnetNodeOptions mkPoolNodes) <$> - optional $ OA.option ensureAtLeastOne - ( OA.long "num-pool-nodes" - <> OA.help "Number of pool nodes. Note this uses a default node configuration for all nodes." - <> OA.metavar "COUNT" - ) + pNodes <|> pNumPoolNodes <|> pure cardanoDefaultTestnetNodeOptions where - defaultSpoOption = NodeOptions [] - - mkPoolNodes num = TestnetNodeOptions - { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption - , optRelayNodes = [] - } + pNumPoolNodes :: Parser TestnetNodeOptions + pNumPoolNodes = + (\num -> TestnetNodeOptions { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption, optRelayNodes = [] }) <$> + OA.option ensureAtLeastOne + ( OA.long "num-pool-nodes" + <> OA.help "Number of pool nodes. Note this uses a default node configuration for all nodes." + <> OA.metavar "COUNT" + ) + defaultSpoOption = NodeOptions Nothing [] ensureAtLeastOne :: OA.ReadM Int ensureAtLeastOne = readerAsk >>= \arg -> @@ -127,6 +126,55 @@ pTestnetNodeOptions = Just n | n >= 1 -> pure n _ -> fail "Need at least one SPO node to produce blocks, but got none." + pNodes :: Parser TestnetNodeOptions + pNodes = OA.option readNodeSpecs + ( OA.long "nodes" + <> OA.help "Comma-separated node specifications. SPO nodes must come before relay nodes. \ + \Each spec is a role (spo or relay) optionally followed by key=value pairs \ + \separated by colons. \ + \Example: --nodes spo,spo:node-bin=/path/to/bin,relay,relay" + <> OA.metavar "SPEC[,SPEC...]" + ) + + readNodeSpecs :: OA.ReadM TestnetNodeOptions + readNodeSpecs = readerAsk >>= \arg -> + case mapM parseNodeSpec (splitOnChar ',' arg) of + Right specs -> do + let (spos, relays) = span (\(role, _) -> role == "spo") specs + unless (all (\(role, _) -> role == "relay") relays) $ + fail "SPO nodes must come before relay nodes. \ + \Example: --nodes spo,spo,relay,relay" + case spos of + [] -> fail "Need at least one SPO node to produce blocks." + ((_,s):ss) -> pure $ TestnetNodeOptions + { optSpoNodes = s :| map snd ss + , optRelayNodes = map snd relays + } + Left err -> fail err + + parseNodeSpec :: String -> Either String (String, NodeOptions) + parseNodeSpec spec = case splitOnChar ':' spec of + [] -> Left "Empty node specification." + (role:kvs) -> do + unless (role == "spo" || role == "relay") $ + Left $ "Unknown node role: '" ++ role ++ "'. Expected 'spo' or 'relay'." + bin <- parseKVs kvs + Right (role, NodeOptions bin []) + + parseKVs :: [String] -> Either String (Maybe FilePath) + parseKVs [] = Right Nothing + parseKVs [kv] = case break (== '=') kv of + ("node-bin", '=':path) | not (null path) -> Right (Just path) + ("node-bin", _) -> Left "node-bin requires a non-empty path, e.g. node-bin=/path/to/binary" + (key, _) -> Left $ "Unknown node option: '" ++ key ++ "'. Known options: node-bin" + parseKVs _ = Left "Multiple key=value pairs are not yet supported." + + splitOnChar :: Char -> String -> [String] + splitOnChar _ [] = [""] + splitOnChar sep s = case break (== sep) s of + (w, []) -> [w] + (w, _:rest) -> w : splitOnChar sep rest + pOnChainParams :: Parser TestnetOnChainParams pOnChainParams = fmap (fromMaybe DefaultParams) <$> optional $ pCustomParamsFile <|> pMainnetParams diff --git a/cardano-testnet/src/Testnet/Runtime.hs b/cardano-testnet/src/Testnet/Runtime.hs index bd5dbe1527c..3458d48891c 100644 --- a/cardano-testnet/src/Testnet/Runtime.hs +++ b/cardano-testnet/src/Testnet/Runtime.hs @@ -121,11 +121,13 @@ startNode -- ^ Node port -> Int -- ^ Testnet magic + -> Maybe FilePath + -- ^ Optional custom node binary. 'Nothing' uses the default resolution. -> [String] -- ^ The command to execute to start the node. -- @--socket-path@, @--port@, and @--host-addr@ gets added automatically. -> ExceptT NodeStartFailure m TestnetNode -startNode tp node ipv4 port _testnetMagic nodeCmd = GHC.withFrozenCallStack $ do +startNode tp node ipv4 port _testnetMagic mNodeBin nodeCmd = GHC.withFrozenCallStack $ do let tempBaseAbsPath = makeTmpBaseAbsPath tp socketDir = makeSocketDir tp logDir = makeLogDir tp @@ -156,7 +158,10 @@ startNode tp node ipv4 port _testnetMagic nodeCmd = GHC.withFrozenCallStack $ do , "--port", show port , "--host-addr", showIpv4Address ipv4 ] - nodeProcess <- newExceptT . fmap (first ExecutableRelatedFailure) . try $ runRIO () $ procNode completeNodeCmd + nodeProcess <- newExceptT . fmap (first ExecutableRelatedFailure) . try $ runRIO () $ + case mNodeBin of + Nothing -> procNode completeNodeCmd + Just bin -> pure (IO.proc bin completeNodeCmd){ IO.create_group = True } -- The port number if it is obtained using 'H.randomPort', it is firstly bound to and then closed. The closing -- and release in the operating system is done asynchronously and can be slow. Here we wait until the port diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index 0395bd1ba91..0d1194711f5 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -52,6 +52,7 @@ import Control.Monad.Catch import Control.Monad.Trans.Resource (MonadResource, getInternalState) import Data.Aeson import qualified Data.Aeson.Encode.Pretty as A +import qualified Data.Yaml as Yaml import qualified Data.ByteString.Lazy as LBS import Data.Default.Class () import Data.Either @@ -67,12 +68,13 @@ import Data.Time.Clock (NominalDiffTime) import qualified Data.Time.Clock as DTC import GHC.Stack import qualified System.Directory as IO +import qualified System.Process as Process import System.FilePath (()) import Testnet.Components.Configuration import qualified Testnet.Defaults as Defaults -import Cardano.Node.Testnet.Paths (defaultConfigFile, defaultPortFile, - defaultUtxoAddrPath) +import Cardano.Node.Testnet.Paths (defaultConfigFile, defaultNodeEnvFile, + defaultPortFile, defaultUtxoAddrPath) import Testnet.Filepath import Testnet.Handlers (interruptNodesOnSigINT) import Testnet.Orphans () @@ -93,6 +95,7 @@ import RIO.State (put) import UnliftIO.Async import UnliftIO.Exception (stringException) + liftToIntegration :: HasCallStack => RIO ResourceMap a -> H.Integration a liftToIntegration r = do rMap <- lift $ lift getInternalState @@ -139,8 +142,8 @@ createTestnetEnv let portNumbersMap = Map.fromList portNumbers - -- Create network topology and write port files - forM_ nodeIds $ \i -> do + -- Create network topology, write port files, and write env files for custom binaries + forM_ numberedNodes $ \(i, nodeOption) -> do let nodeDataDir = tmpAbsPath Defaults.defaultNodeDataDir i liftIOAnnotated $ IO.createDirectoryIfMissing True nodeDataDir @@ -153,6 +156,16 @@ createTestnetEnv let topology = Defaults.defaultP2PTopology producers liftIOAnnotated . LBS.writeFile (nodeDataDir "topology.json") $ A.encodePretty topology + -- Write env file for nodes with custom binaries + case nodeBin nodeOption of + Nothing -> pure () + Just bin -> do + absBin <- liftIOAnnotated $ IO.makeAbsolute bin + version <- getNodeVersion absBin + let envFile = tmpAbsPath defaultNodeEnvFile i + nodeEnv = NodeEnv { node_binary = absBin, node_version = version } + liftIOAnnotated $ Yaml.encodeFile envFile nodeEnv + -- | Starts a number of nodes, as given by the first argument. You can either: -- -- 1. Pass a value 'UserProvidedNodeOptions filepath' to specify your own node configuration file. @@ -318,41 +331,41 @@ cardanoTestnet nodeDataDir = tmpAbsPath Defaults.defaultNodeDataDir i nodePoolKeysDir = tmpAbsPath Defaults.defaultSpoKeysDir i (mKeys, spoNodeCliArgs) <- if not isSpo then pure (Nothing, []) else do - -- depending on testnet configuration, either start a 'kes-agent' or use a key from disk - kesSourceCliArg <- - case cardanoKESSource of - UseKesKeyFile -> pure ["--shelley-kes-key", nodePoolKeysDir "kes.skey"] - UseKesSocket -> do - -- wait startTimeOffsetSeconds so that the startTime from shelly-genesis.json is not in the future, - -- as otherwise we will trigger an underflow in kes-agent with a negative time difference. - liftIOAnnotated $ threadDelay (startTimeOffsetSeconds * 1_000_000) - kesAgent <- runExceptT $ - initAndStartKesAgent (TmpAbsolutePath tmpAbsPath) nodeName - TestnetKesAgentArgs{ tkaaShelleyGenesisFile = shelleyGenesisFile - , tkaaColdVKeyFile = nodePoolKeysDir "cold.vkey" - , tkaaColdSKeyFile = nodePoolKeysDir "cold.skey" - , tkaaKesVKeyFile = nodePoolKeysDir "kes.vkey" - , tkaaOpcertCounterFile = nodePoolKeysDir "opcert.counter" - , tkaaOpcertFile = nodePoolKeysDir "opcert.cert" - } - case kesAgent of - Left e -> do - -- TODO: fail if could not start KES agent - liftIOAnnotated . putStrLn $ "Could not start KES agent: " <> show e - pure ["--shelley-kes-key", nodePoolKeysDir "kes.skey"] - Right (TestnetKesAgent{kesAgentServiceSprocket}) -> - pure ["--shelley-kes-agent-socket", sprocketSystemName kesAgentServiceSprocket] - let shelleyCliArgs = [ "--shelley-vrf-key", unFile $ signingKey poolNodeKeysVrf - , "--shelley-operational-certificate", nodePoolKeysDir "opcert.cert" - ] - byronCliArgs = [ "--byron-delegation-certificate", nodePoolKeysDir "byron-delegation.cert" - , "--byron-signing-key", nodePoolKeysDir "byron-delegate.key" - ] - keys@SpoNodeKeys{poolNodeKeysVrf} = mkTestnetNodeKeyPaths i - pure (Just keys, kesSourceCliArg <> shelleyCliArgs <> byronCliArgs) + -- depending on testnet configuration, either start a 'kes-agent' or use a key from disk + kesSourceCliArg <- + case cardanoKESSource of + UseKesKeyFile -> pure ["--shelley-kes-key", nodePoolKeysDir "kes.skey"] + UseKesSocket -> do + -- wait startTimeOffsetSeconds so that the startTime from shelly-genesis.json is not in the future, + -- as otherwise we will trigger an underflow in kes-agent with a negative time difference. + liftIOAnnotated $ threadDelay (startTimeOffsetSeconds * 1_000_000) + kesAgent <- runExceptT $ + initAndStartKesAgent (TmpAbsolutePath tmpAbsPath) nodeName + TestnetKesAgentArgs{ tkaaShelleyGenesisFile = shelleyGenesisFile + , tkaaColdVKeyFile = nodePoolKeysDir "cold.vkey" + , tkaaColdSKeyFile = nodePoolKeysDir "cold.skey" + , tkaaKesVKeyFile = nodePoolKeysDir "kes.vkey" + , tkaaOpcertCounterFile = nodePoolKeysDir "opcert.counter" + , tkaaOpcertFile = nodePoolKeysDir "opcert.cert" + } + case kesAgent of + Left e -> do + -- TODO: fail if could not start KES agent + liftIOAnnotated . putStrLn $ "Could not start KES agent: " <> show e + pure ["--shelley-kes-key", nodePoolKeysDir "kes.skey"] + Right (TestnetKesAgent{kesAgentServiceSprocket}) -> + pure ["--shelley-kes-agent-socket", sprocketSystemName kesAgentServiceSprocket] + let shelleyCliArgs = [ "--shelley-vrf-key", unFile $ signingKey poolNodeKeysVrf + , "--shelley-operational-certificate", nodePoolKeysDir "opcert.cert" + ] + byronCliArgs = [ "--byron-delegation-certificate", nodePoolKeysDir "byron-delegation.cert" + , "--byron-signing-key", nodePoolKeysDir "byron-delegate.key" + ] + keys@SpoNodeKeys{poolNodeKeysVrf} = mkTestnetNodeKeyPaths i + pure (Just keys, kesSourceCliArg <> shelleyCliArgs <> byronCliArgs) eRuntime <- runExceptT . retryOnAddressInUseError $ - startNode (TmpAbsolutePath tmpAbsPath) nodeName testnetDefaultIpv4Address port testnetMagic $ + startNode (TmpAbsolutePath tmpAbsPath) nodeName testnetDefaultIpv4Address port testnetMagic (nodeBin nodeOptions) $ [ "run" , "--config", nodeConfigFile , "--topology", nodeDataDir "topology.json" @@ -517,8 +530,8 @@ readNodeOptionsFromEnv envDir = do when (null spoFlags) $ throwString "No SPO node directories found in environment" let nSpos = length spoFlags - let spoOpts = map (const (NodeOptions [])) [1 .. nSpos] - relayOpts = map (const (NodeOptions [])) [nSpos + 1 .. length nodeNums] + spoOpts <- mapM readNodeOpt [1 .. nSpos] + relayOpts <- mapM readNodeOpt [nSpos + 1 .. length nodeNums] case spoOpts of (s:ss) -> pure $ TestnetNodeOptions { optSpoNodes = s :| ss, optRelayNodes = relayOpts } [] -> throwString "No SPO node directories found in environment" @@ -526,3 +539,40 @@ readNodeOptionsFromEnv envDir = do parseNodeNum s = do rest <- stripPrefix "node" s readMaybe rest :: Maybe Int + readNodeOpt i = do + bin <- readNodeBinFromEnvFile (envDir defaultNodeEnvFile i) + pure $ NodeOptions bin [] + +data NodeEnv = NodeEnv + { node_binary :: FilePath + , node_version :: String + } deriving (Eq, Show) + +instance FromJSON NodeEnv where + parseJSON = withObject "NodeEnv" $ \o -> + NodeEnv <$> o .: "node_binary" + <*> o .: "node_version" + +instance ToJSON NodeEnv where + toJSON NodeEnv{node_binary, node_version} = + object [ "node_binary" .= node_binary + , "node_version" .= node_version + ] + +readNodeBinFromEnvFile :: MonadIO m => FilePath -> m (Maybe FilePath) +readNodeBinFromEnvFile envFile = liftIO $ do + exists <- IO.doesFileExist envFile + if not exists + then pure Nothing + else do + result <- Yaml.decodeFileEither envFile + case result of + Right NodeEnv{node_binary} -> pure (Just node_binary) + Left err -> throwString $ "Failed to parse node env file " <> envFile <> ": " <> show err + +getNodeVersion :: MonadIO m => FilePath -> m String +getNodeVersion bin = liftIO $ do + output <- Process.readProcess bin ["--version"] "" + case words output of + ("cardano-node":version:_) -> pure version + _ -> throwString $ "Unexpected output from " <> bin <> " --version (expected 'cardano-node ...'): " <> output diff --git a/cardano-testnet/src/Testnet/Start/Types.hs b/cardano-testnet/src/Testnet/Start/Types.hs index c221f451480..0df1692911f 100644 --- a/cardano-testnet/src/Testnet/Start/Types.hs +++ b/cardano-testnet/src/Testnet/Start/Types.hs @@ -259,8 +259,9 @@ instance Default GenesisOptions where } -- | Configuration specific to each node -newtype NodeOptions = NodeOptions - { nodeExtraCliArgs :: [String] -- ^ Extra CLI arguments passed to @cardano-node run@ +data NodeOptions = NodeOptions + { nodeBin :: Maybe FilePath -- ^ Path to the @cardano-node@ binary to use for running this node. 'Nothing' uses the default resolution mechanism. + , nodeExtraCliArgs :: [String] -- ^ Extra CLI arguments passed to @cardano-node run@ } deriving (Eq, Show) -- | Specifies the nodes to create for the testnet, split by role (SPO and relay). @@ -282,9 +283,9 @@ instance Default (UserProvidedData a) where cardanoDefaultTestnetNodeOptions :: TestnetNodeOptions cardanoDefaultTestnetNodeOptions = TestnetNodeOptions - { optSpoNodes = NodeOptions [] :| [] - , optRelayNodes = [ NodeOptions [] - , NodeOptions [] + { optSpoNodes = NodeOptions Nothing [] :| [] + , optRelayNodes = [ NodeOptions Nothing [] + , NodeOptions Nothing [] ] } diff --git a/cardano-testnet/test/cardano-testnet-golden/files/golden/help.cli b/cardano-testnet/test/cardano-testnet-golden/files/golden/help.cli index 3338b742378..727d84e5bb3 100644 --- a/cardano-testnet/test/cardano-testnet-golden/files/golden/help.cli +++ b/cardano-testnet/test/cardano-testnet-golden/files/golden/help.cli @@ -2,7 +2,7 @@ Usage: cardano-testnet (cardano | create-env | version | help) Usage: cardano-testnet cardano [ --node-env FILEPATH [--preserve-timestamps] - | [--num-pool-nodes COUNT] + | [--nodes SPEC[,SPEC...] | --num-pool-nodes COUNT] [--max-lovelace-supply WORD64] [--num-dreps NUMBER] [--testnet-magic INT] @@ -18,7 +18,8 @@ Usage: cardano-testnet cardano Start a testnet and keep it running until stopped -Usage: cardano-testnet create-env [--num-pool-nodes COUNT] +Usage: cardano-testnet create-env + [--nodes SPEC[,SPEC...] | --num-pool-nodes COUNT] [--max-lovelace-supply WORD64] [--num-dreps NUMBER] [--testnet-magic INT] diff --git a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli index af2f574cdec..97698c3b702 100644 --- a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli +++ b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli @@ -1,6 +1,6 @@ Usage: cardano-testnet cardano [ --node-env FILEPATH [--preserve-timestamps] - | [--num-pool-nodes COUNT] + | [--nodes SPEC[,SPEC...] | --num-pool-nodes COUNT] [--max-lovelace-supply WORD64] [--num-dreps NUMBER] [--testnet-magic INT] @@ -23,6 +23,11 @@ Available options: pass it with this argument. --preserve-timestamps Do not update the time stamps in genesis files to current date. + --nodes SPEC[,SPEC...] Comma-separated node specifications. SPO nodes must + come before relay nodes. Each spec is a role (spo or + relay) optionally followed by key=value pairs + separated by colons. Example: --nodes + spo,spo:node-bin=/path/to/bin,relay,relay --num-pool-nodes COUNT Number of pool nodes. Note this uses a default node configuration for all nodes. --max-lovelace-supply WORD64 diff --git a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli index 20d7b4405c6..60144513c68 100644 --- a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli +++ b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli @@ -1,4 +1,5 @@ -Usage: cardano-testnet create-env [--num-pool-nodes COUNT] +Usage: cardano-testnet create-env + [--nodes SPEC[,SPEC...] | --num-pool-nodes COUNT] [--max-lovelace-supply WORD64] [--num-dreps NUMBER] [--testnet-magic INT] @@ -11,6 +12,11 @@ Usage: cardano-testnet create-env [--num-pool-nodes COUNT] Create a sandbox for Cardano testnet Available options: + --nodes SPEC[,SPEC...] Comma-separated node specifications. SPO nodes must + come before relay nodes. Each spec is a role (spo or + relay) optionally followed by key=value pairs + separated by colons. Example: --nodes + spo,spo:node-bin=/path/to/bin,relay,relay --num-pool-nodes COUNT Number of pool nodes. Note this uses a default node configuration for all nodes. --max-lovelace-supply WORD64 diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/KesPeriodInfo.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/KesPeriodInfo.hs index bb8f062593b..fb3db527b5d 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/KesPeriodInfo.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/KesPeriodInfo.hs @@ -262,7 +262,7 @@ hprop_kes_period_info = integrationRetryWorkspace 2 "kes-period-info" $ \tempAbs H.lbsWriteFile (unFile configurationFile) jsonBS newNodePortNumber <- H.randomPort testnetDefaultIpv4Address eRuntime <- runExceptT . retryOnAddressInUseError $ - startNode tempAbsPath "test-spo" testnetDefaultIpv4Address newNodePortNumber testnetMagic + startNode tempAbsPath "test-spo" testnetDefaultIpv4Address newNodePortNumber testnetMagic Nothing [ "run" , "--config", unFile configurationFile , "--topology", topologyFile diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs index 91e98ad35ea..d57a66ef90b 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs @@ -72,9 +72,9 @@ hprop_leadershipSchedule = integrationRetryWorkspace 2 "leadership-schedule" $ \ { creationEra = asbe , creationNodes = TestnetNodeOptions - { optSpoNodes = NodeOptions [] :| - [ NodeOptions [] - , NodeOptions [] + { optSpoNodes = NodeOptions Nothing [] :| + [ NodeOptions Nothing [] + , NodeOptions Nothing [] ] , optRelayNodes = [] } @@ -267,7 +267,7 @@ hprop_leadershipSchedule = integrationRetryWorkspace 2 "leadership-schedule" $ \ H.lbsWriteFile (unFile configurationFile) jsonBS newNodePort <- H.randomPort testnetDefaultIpv4Address eRuntime <- runExceptT . retryOnAddressInUseError $ - startNode (TmpAbsolutePath work) "test-spo" testnetDefaultIpv4Address newNodePort testnetMagic + startNode (TmpAbsolutePath work) "test-spo" testnetDefaultIpv4Address newNodePort testnetMagic Nothing [ "run" , "--config", unFile configurationFile , "--topology", topologyFile diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs index 5e810cb287a..e19f38c8c59 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs @@ -60,9 +60,9 @@ hprop_ledger_events_propose_new_constitution_spo = integrationRetryWorkspace 2 " { creationEra = AnyShelleyBasedEra sbe , creationNodes = TestnetNodeOptions - { optSpoNodes = NodeOptions [] :| - [ NodeOptions [] - , NodeOptions [] + { optSpoNodes = NodeOptions Nothing [] :| + [ NodeOptions Nothing [] + , NodeOptions Nothing [] ] , optRelayNodes = [] } diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs index 00408cfcdeb..7fd79f6423f 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs @@ -209,7 +209,7 @@ hprop_shutdownOnSlotSynced = integrationRetryWorkspace 2 "shutdown-on-slot-synce let creationOptions = def { creationNodes = TestnetNodeOptions - { optSpoNodes = NodeOptions ["--shutdown-on-slot-synced", show maxSlot] :| [] + { optSpoNodes = NodeOptions Nothing ["--shutdown-on-slot-synced", show maxSlot] :| [] , optRelayNodes = [] } , creationGenesisOptions = def From 5f4173bb0c18f56ca4025a9d53549ecd1e79babb Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Wed, 6 May 2026 03:58:18 +0200 Subject: [PATCH 2/9] Add changelog entry for --nodes flag --- ...20260506_035740_palas_testnet_specify_node_bin_per_node.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 cardano-testnet/changelog.d/20260506_035740_palas_testnet_specify_node_bin_per_node.md diff --git a/cardano-testnet/changelog.d/20260506_035740_palas_testnet_specify_node_bin_per_node.md b/cardano-testnet/changelog.d/20260506_035740_palas_testnet_specify_node_bin_per_node.md new file mode 100644 index 00000000000..2ece341692e --- /dev/null +++ b/cardano-testnet/changelog.d/20260506_035740_palas_testnet_specify_node_bin_per_node.md @@ -0,0 +1,4 @@ +### Added + +- Added `--nodes` flag to specify node roles (SPO/relay) and custom `cardano-node` binaries per node. + Example: `--nodes spo,spo:node-bin=/path/to/bin,relay,relay`. From 71d46654cb3065be5a7382780be0299afd394a1c Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Thu, 7 May 2026 20:57:46 +0200 Subject: [PATCH 3/9] Apply code suggestions by @carbolymer Co-authored-by: Mateusz Galazyn <228866+carbolymer@users.noreply.github.com> --- cardano-testnet/src/Testnet/Start/Cardano.hs | 28 +++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index 0d1194711f5..cc90e4b73ba 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -47,7 +47,8 @@ import Ouroboros.Network.PeerSelection.RelayAccessPoint (RelayAccessPo import Prelude hiding (lines) import Control.Concurrent (threadDelay) -import Control.Monad (forM, forM_, unless, when) +import Control.Monad (forM, forM_, guard, unless, when) +import Control.Monad.Trans.Maybe (runMaybeT) import Control.Monad.Catch import Control.Monad.Trans.Resource (MonadResource, getInternalState) import Data.Aeson @@ -157,9 +158,7 @@ createTestnetEnv liftIOAnnotated . LBS.writeFile (nodeDataDir "topology.json") $ A.encodePretty topology -- Write env file for nodes with custom binaries - case nodeBin nodeOption of - Nothing -> pure () - Just bin -> do + forM_ (nodeBin nodeOption) $ \bin -> do absBin <- liftIOAnnotated $ IO.makeAbsolute bin version <- getNodeVersion absBin let envFile = tmpAbsPath defaultNodeEnvFile i @@ -559,19 +558,16 @@ instance ToJSON NodeEnv where , "node_version" .= node_version ] -readNodeBinFromEnvFile :: MonadIO m => FilePath -> m (Maybe FilePath) -readNodeBinFromEnvFile envFile = liftIO $ do - exists <- IO.doesFileExist envFile - if not exists - then pure Nothing - else do - result <- Yaml.decodeFileEither envFile - case result of - Right NodeEnv{node_binary} -> pure (Just node_binary) - Left err -> throwString $ "Failed to parse node env file " <> envFile <> ": " <> show err +readNodeBinFromEnvFile :: (HasCallStack, MonadIO m) => FilePath -> m (Maybe FilePath) +readNodeBinFromEnvFile envFile = runMaybeT $ do + guard =<< liftIOAnnotated (IO.doesFileExist envFile) + NodeEnv{node_binary} <- either failParse pure =<< liftIOAnnotated (Yaml.decodeFileEither envFile) + pure node_binary + where + failParse err = throwString $ "Failed to parse node env file " <> envFile <> ": " <> show err -getNodeVersion :: MonadIO m => FilePath -> m String -getNodeVersion bin = liftIO $ do +getNodeVersion :: HasCallStack => MonadIO m => FilePath -> m String +getNodeVersion bin = liftIOAnnotated $ do output <- Process.readProcess bin ["--version"] "" case words output of ("cardano-node":version:_) -> pure version From 059a5364cb8953c61a818d1649e86c8000e4b0a9 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 8 May 2026 00:03:44 +0200 Subject: [PATCH 4/9] Use camelCase for `NodeEnv` field names --- cardano-testnet/src/Testnet/Start/Cardano.hs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index cc90e4b73ba..22f528437a4 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -162,7 +162,7 @@ createTestnetEnv absBin <- liftIOAnnotated $ IO.makeAbsolute bin version <- getNodeVersion absBin let envFile = tmpAbsPath defaultNodeEnvFile i - nodeEnv = NodeEnv { node_binary = absBin, node_version = version } + nodeEnv = NodeEnv { nodeBinary = absBin, nodeVersion = version } liftIOAnnotated $ Yaml.encodeFile envFile nodeEnv -- | Starts a number of nodes, as given by the first argument. You can either: @@ -543,8 +543,8 @@ readNodeOptionsFromEnv envDir = do pure $ NodeOptions bin [] data NodeEnv = NodeEnv - { node_binary :: FilePath - , node_version :: String + { nodeBinary :: FilePath + , nodeVersion :: String } deriving (Eq, Show) instance FromJSON NodeEnv where @@ -553,16 +553,16 @@ instance FromJSON NodeEnv where <*> o .: "node_version" instance ToJSON NodeEnv where - toJSON NodeEnv{node_binary, node_version} = - object [ "node_binary" .= node_binary - , "node_version" .= node_version + toJSON NodeEnv{nodeBinary, nodeVersion} = + object [ "node_binary" .= nodeBinary + , "node_version" .= nodeVersion ] readNodeBinFromEnvFile :: (HasCallStack, MonadIO m) => FilePath -> m (Maybe FilePath) readNodeBinFromEnvFile envFile = runMaybeT $ do guard =<< liftIOAnnotated (IO.doesFileExist envFile) - NodeEnv{node_binary} <- either failParse pure =<< liftIOAnnotated (Yaml.decodeFileEither envFile) - pure node_binary + NodeEnv{nodeBinary} <- either failParse pure =<< liftIOAnnotated (Yaml.decodeFileEither envFile) + pure nodeBinary where failParse err = throwString $ "Failed to parse node env file " <> envFile <> ": " <> show err From 6ebb57afdbffc712ad0099265a8d7e133949c434 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 8 May 2026 00:05:55 +0200 Subject: [PATCH 5/9] Catch and rethrow with better error info in `getNodeVersion` --- cardano-testnet/src/Testnet/Start/Cardano.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index 22f528437a4..da6b27eadc6 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -49,6 +49,7 @@ import Prelude hiding (lines) import Control.Concurrent (threadDelay) import Control.Monad (forM, forM_, guard, unless, when) import Control.Monad.Trans.Maybe (runMaybeT) +import Control.Exception (IOException) import Control.Monad.Catch import Control.Monad.Trans.Resource (MonadResource, getInternalState) import Data.Aeson @@ -569,6 +570,8 @@ readNodeBinFromEnvFile envFile = runMaybeT $ do getNodeVersion :: HasCallStack => MonadIO m => FilePath -> m String getNodeVersion bin = liftIOAnnotated $ do output <- Process.readProcess bin ["--version"] "" + `catch` \(e :: IOException) -> + throwString $ "Failed to run " <> bin <> " --version: " <> displayException e case words output of ("cardano-node":version:_) -> pure version _ -> throwString $ "Unexpected output from " <> bin <> " --version (expected 'cardano-node ...'): " <> output From 903e23ad117e44ba029adbde79403110db554103 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 8 May 2026 00:09:08 +0200 Subject: [PATCH 6/9] Use parsec for `--nodes` parser with quoted path support --- cardano-testnet/cardano-testnet.cabal | 1 + cardano-testnet/src/Parsers/Cardano.hs | 94 +++++++++++++++----------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/cardano-testnet/cardano-testnet.cabal b/cardano-testnet/cardano-testnet.cabal index 43b7a3cea20..395ba15ec78 100644 --- a/cardano-testnet/cardano-testnet.cabal +++ b/cardano-testnet/cardano-testnet.cabal @@ -84,6 +84,7 @@ library , network , network-mux , optparse-applicative-fork + , parsec , ouroboros-network:{api, framework, ouroboros-network} ^>= 1.1 , cardano-diffusion:{api, cardano-diffusion} ^>= 1.0 , prettyprinter diff --git a/cardano-testnet/src/Parsers/Cardano.hs b/cardano-testnet/src/Parsers/Cardano.hs index e45e5a02a9d..f049559d90e 100644 --- a/cardano-testnet/src/Parsers/Cardano.hs +++ b/cardano-testnet/src/Parsers/Cardano.hs @@ -3,6 +3,7 @@ module Parsers.Cardano ( cmdCardano , cmdCreateEnv + , parseNodeSpecs ) where import Cardano.Api (AnyShelleyBasedEra (..)) @@ -22,6 +23,9 @@ import Data.Word (Word64) import Options.Applicative (CommandFields, Mod, Parser) import qualified Options.Applicative as OA import Options.Applicative.Types (readerAsk) +import Text.Parsec (char, many1, noneOf, + sepBy1, string, try, (), parse, eof, notFollowedBy) +import qualified Text.Parsec as Parsec import Testnet.Defaults (defaultEra) import Testnet.Start.Cardano @@ -130,50 +134,60 @@ pTestnetNodeOptions = pNodes = OA.option readNodeSpecs ( OA.long "nodes" <> OA.help "Comma-separated node specifications. SPO nodes must come before relay nodes. \ - \Each spec is a role (spo or relay) optionally followed by key=value pairs \ - \separated by colons. \ - \Example: --nodes spo,spo:node-bin=/path/to/bin,relay,relay" + \Each spec is a role (spo or relay) optionally followed by :node-bin=. \ + \If the path contains commas, colons, double quotes, or backslashes, wrap it \ + \in double quotes and escape any literal double quotes as \\\" and backslashes \ + \as \\\\ within. To prevent bash from consuming the double quotes, enclose the \ + \whole argument in single quotes. \ + \Examples: --nodes spo,spo:node-bin=/path/to/bin,relay,relay | \ + \--nodes 'spo:node-bin=\"/path,with:commas\",relay'" <> OA.metavar "SPEC[,SPEC...]" ) readNodeSpecs :: OA.ReadM TestnetNodeOptions - readNodeSpecs = readerAsk >>= \arg -> - case mapM parseNodeSpec (splitOnChar ',' arg) of - Right specs -> do - let (spos, relays) = span (\(role, _) -> role == "spo") specs - unless (all (\(role, _) -> role == "relay") relays) $ - fail "SPO nodes must come before relay nodes. \ - \Example: --nodes spo,spo,relay,relay" - case spos of - [] -> fail "Need at least one SPO node to produce blocks." - ((_,s):ss) -> pure $ TestnetNodeOptions - { optSpoNodes = s :| map snd ss - , optRelayNodes = map snd relays - } - Left err -> fail err - - parseNodeSpec :: String -> Either String (String, NodeOptions) - parseNodeSpec spec = case splitOnChar ':' spec of - [] -> Left "Empty node specification." - (role:kvs) -> do - unless (role == "spo" || role == "relay") $ - Left $ "Unknown node role: '" ++ role ++ "'. Expected 'spo' or 'relay'." - bin <- parseKVs kvs - Right (role, NodeOptions bin []) - - parseKVs :: [String] -> Either String (Maybe FilePath) - parseKVs [] = Right Nothing - parseKVs [kv] = case break (== '=') kv of - ("node-bin", '=':path) | not (null path) -> Right (Just path) - ("node-bin", _) -> Left "node-bin requires a non-empty path, e.g. node-bin=/path/to/binary" - (key, _) -> Left $ "Unknown node option: '" ++ key ++ "'. Known options: node-bin" - parseKVs _ = Left "Multiple key=value pairs are not yet supported." - - splitOnChar :: Char -> String -> [String] - splitOnChar _ [] = [""] - splitOnChar sep s = case break (== sep) s of - (w, []) -> [w] - (w, _:rest) -> w : splitOnChar sep rest + readNodeSpecs = readerAsk >>= either (fail . show) pure . parseNodeSpecs + +-- | Parse a @--nodes@ argument string into 'TestnetNodeOptions'. +parseNodeSpecs :: String -> Either Parsec.ParseError TestnetNodeOptions +parseNodeSpecs = parse (nodeSpecsParser <* eof) "Error parsing node specifications" + where + nodeSpecsParser :: Parsec.Parsec String () TestnetNodeOptions + nodeSpecsParser = do + specs <- nodeSpec `sepBy1` char ',' + let (spos, relays) = span (\(role, _) -> role == Spo) specs + unless (all (\(role, _) -> role == Relay) relays) $ + fail "SPO nodes must come before relay nodes. Example: --nodes spo,spo,relay,relay" + case map snd spos of + [] -> fail "Need at least one SPO node to produce blocks." + (s:ss) -> pure $ TestnetNodeOptions + { optSpoNodes = s :| ss + , optRelayNodes = map snd relays + } + + nodeSpec :: Parsec.Parsec String () (NodeRole, NodeOptions) + nodeSpec = do + role <- nodeRole + bin <- optional $ char ':' *> nodeBinKV + pure (role, NodeOptions bin []) + + nodeRole :: Parsec.Parsec String () NodeRole + nodeRole = + Spo <$ try (string "spo" <* notFollowedBy (noneOf ",:\"\\")) + <|> Relay <$ try (string "relay" <* notFollowedBy (noneOf ",:\"\\")) + "node role (\"spo\" or \"relay\")" + + nodeBinKV :: Parsec.Parsec String () FilePath + nodeBinKV = string "node-bin=" *> (quotedPath <|> unquotedPath) "\"node-bin=\", where is the path to the node binary, optionally quoted if it contains special characters" + + quotedPath :: Parsec.Parsec String () FilePath + quotedPath = char '"' *> Parsec.many quotedChar <* char '"' + where + quotedChar = try (char '\\' *> (char '"' <|> char '\\')) <|> noneOf "\"" + + unquotedPath :: Parsec.Parsec String () FilePath + unquotedPath = many1 (noneOf ",:\"\\") + +data NodeRole = Spo | Relay deriving Eq pOnChainParams :: Parser TestnetOnChainParams pOnChainParams = fmap (fromMaybe DefaultParams) <$> optional $ From 6fe135ef1041499afa91f06c0c723e8ded0b95b3 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 8 May 2026 02:39:34 +0200 Subject: [PATCH 7/9] Update golden files --- .../files/golden/help/cardano.cli | 12 +++++++++--- .../files/golden/help/create-env.cli | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli index 97698c3b702..cad989c099a 100644 --- a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli +++ b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/cardano.cli @@ -25,9 +25,15 @@ Available options: current date. --nodes SPEC[,SPEC...] Comma-separated node specifications. SPO nodes must come before relay nodes. Each spec is a role (spo or - relay) optionally followed by key=value pairs - separated by colons. Example: --nodes - spo,spo:node-bin=/path/to/bin,relay,relay + relay) optionally followed by :node-bin=. If + the path contains commas, colons, double quotes, or + backslashes, wrap it in double quotes and escape any + literal double quotes as \" and backslashes as \\ + within. To prevent bash from consuming the double + quotes, enclose the whole argument in single quotes. + Examples: --nodes + spo,spo:node-bin=/path/to/bin,relay,relay | --nodes + 'spo:node-bin="/path,with:commas",relay' --num-pool-nodes COUNT Number of pool nodes. Note this uses a default node configuration for all nodes. --max-lovelace-supply WORD64 diff --git a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli index 60144513c68..27776f085e5 100644 --- a/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli +++ b/cardano-testnet/test/cardano-testnet-golden/files/golden/help/create-env.cli @@ -14,9 +14,15 @@ Usage: cardano-testnet create-env Available options: --nodes SPEC[,SPEC...] Comma-separated node specifications. SPO nodes must come before relay nodes. Each spec is a role (spo or - relay) optionally followed by key=value pairs - separated by colons. Example: --nodes - spo,spo:node-bin=/path/to/bin,relay,relay + relay) optionally followed by :node-bin=. If + the path contains commas, colons, double quotes, or + backslashes, wrap it in double quotes and escape any + literal double quotes as \" and backslashes as \\ + within. To prevent bash from consuming the double + quotes, enclose the whole argument in single quotes. + Examples: --nodes + spo,spo:node-bin=/path/to/bin,relay,relay | --nodes + 'spo:node-bin="/path,with:commas",relay' --num-pool-nodes COUNT Number of pool nodes. Note this uses a default node configuration for all nodes. --max-lovelace-supply WORD64 From 0bbd001f3c903a0043a7925b92daf75155f91422 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 8 May 2026 18:15:40 +0200 Subject: [PATCH 8/9] Rename `NodeOptions` to `NodeWithOptions` and related symbols --- .../test/Spec/Chairman/Cardano.hs | 2 +- cardano-testnet/src/Cardano/Testnet.hs | 6 ++-- cardano-testnet/src/Parsers/Cardano.hs | 30 +++++++++---------- cardano-testnet/src/Parsers/Run.hs | 2 +- cardano-testnet/src/Testnet/Start/Cardano.hs | 28 ++++++++--------- cardano-testnet/src/Testnet/Start/Types.hs | 28 ++++++++--------- .../Testnet/Test/Cli/LeadershipSchedule.hs | 8 ++--- .../Test/Gov/ProposeNewConstitutionSPO.hs | 8 ++--- .../Cardano/Testnet/Test/Node/Shutdown.hs | 4 +-- 9 files changed, 58 insertions(+), 58 deletions(-) diff --git a/cardano-node-chairman/test/Spec/Chairman/Cardano.hs b/cardano-node-chairman/test/Spec/Chairman/Cardano.hs index e443ea2bd07..ce64c4e0a5a 100644 --- a/cardano-node-chairman/test/Spec/Chairman/Cardano.hs +++ b/cardano-node-chairman/test/Spec/Chairman/Cardano.hs @@ -19,7 +19,7 @@ hprop_chairman :: H.Property hprop_chairman = integrationRetryWorkspace 2 "cardano-chairman" $ \tempAbsPath' -> H.runWithDefaultWatchdog_ $ do conf <- mkConf tempAbsPath' - let creationOptions = def{ creationNodes = cardanoDefaultTestnetNodeOptions } + let creationOptions = def{ creationNodes = cardanoDefaultTestnetNodesWithOptions } allNodes <- testnetNodes <$> createAndRunTestnet creationOptions def conf chairmanOver 120 50 conf allNodes diff --git a/cardano-testnet/src/Cardano/Testnet.hs b/cardano-testnet/src/Cardano/Testnet.hs index b965252d776..0c0059789a7 100644 --- a/cardano-testnet/src/Cardano/Testnet.hs +++ b/cardano-testnet/src/Cardano/Testnet.hs @@ -14,9 +14,9 @@ module Cardano.Testnet ( TestnetRuntimeOptions(..), TestnetEnvOptions(..), RpcSupport(..), - TestnetNodeOptions(..), - NodeOptions(..), - cardanoDefaultTestnetNodeOptions, + TestnetNodesWithOptions(..), + NodeWithOptions(..), + cardanoDefaultTestnetNodesWithOptions, getDefaultAlonzoGenesis, getDefaultShelleyGenesis, diff --git a/cardano-testnet/src/Parsers/Cardano.hs b/cardano-testnet/src/Parsers/Cardano.hs index f049559d90e..7ba916f8020 100644 --- a/cardano-testnet/src/Parsers/Cardano.hs +++ b/cardano-testnet/src/Parsers/Cardano.hs @@ -60,7 +60,7 @@ pFromEnv = TestnetEnvOptions pCreationOptions :: Parser TestnetCreationOptions pCreationOptions = TestnetCreationOptions - <$> pTestnetNodeOptions + <$> pTestnetNodesWithOptions <*> pure (AnyShelleyBasedEra defaultEra) <*> pMaxLovelaceSupply <*> pNumDReps @@ -110,19 +110,19 @@ pKesSource = OA.flag UseKesKeyFile UseKesSocket <> OA.showDefault ) -pTestnetNodeOptions :: Parser TestnetNodeOptions -pTestnetNodeOptions = - pNodes <|> pNumPoolNodes <|> pure cardanoDefaultTestnetNodeOptions +pTestnetNodesWithOptions :: Parser TestnetNodesWithOptions +pTestnetNodesWithOptions = + pNodes <|> pNumPoolNodes <|> pure cardanoDefaultTestnetNodesWithOptions where - pNumPoolNodes :: Parser TestnetNodeOptions + pNumPoolNodes :: Parser TestnetNodesWithOptions pNumPoolNodes = - (\num -> TestnetNodeOptions { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption, optRelayNodes = [] }) <$> + (\num -> TestnetNodesWithOptions { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption, optRelayNodes = [] }) <$> OA.option ensureAtLeastOne ( OA.long "num-pool-nodes" <> OA.help "Number of pool nodes. Note this uses a default node configuration for all nodes." <> OA.metavar "COUNT" ) - defaultSpoOption = NodeOptions Nothing [] + defaultSpoOption = NodeWithOptions Nothing [] ensureAtLeastOne :: OA.ReadM Int ensureAtLeastOne = readerAsk >>= \arg -> @@ -130,7 +130,7 @@ pTestnetNodeOptions = Just n | n >= 1 -> pure n _ -> fail "Need at least one SPO node to produce blocks, but got none." - pNodes :: Parser TestnetNodeOptions + pNodes :: Parser TestnetNodesWithOptions pNodes = OA.option readNodeSpecs ( OA.long "nodes" <> OA.help "Comma-separated node specifications. SPO nodes must come before relay nodes. \ @@ -144,14 +144,14 @@ pTestnetNodeOptions = <> OA.metavar "SPEC[,SPEC...]" ) - readNodeSpecs :: OA.ReadM TestnetNodeOptions + readNodeSpecs :: OA.ReadM TestnetNodesWithOptions readNodeSpecs = readerAsk >>= either (fail . show) pure . parseNodeSpecs --- | Parse a @--nodes@ argument string into 'TestnetNodeOptions'. -parseNodeSpecs :: String -> Either Parsec.ParseError TestnetNodeOptions +-- | Parse a @--nodes@ argument string into 'TestnetNodesWithOptions'. +parseNodeSpecs :: String -> Either Parsec.ParseError TestnetNodesWithOptions parseNodeSpecs = parse (nodeSpecsParser <* eof) "Error parsing node specifications" where - nodeSpecsParser :: Parsec.Parsec String () TestnetNodeOptions + nodeSpecsParser :: Parsec.Parsec String () TestnetNodesWithOptions nodeSpecsParser = do specs <- nodeSpec `sepBy1` char ',' let (spos, relays) = span (\(role, _) -> role == Spo) specs @@ -159,16 +159,16 @@ parseNodeSpecs = parse (nodeSpecsParser <* eof) "Error parsing node specificatio fail "SPO nodes must come before relay nodes. Example: --nodes spo,spo,relay,relay" case map snd spos of [] -> fail "Need at least one SPO node to produce blocks." - (s:ss) -> pure $ TestnetNodeOptions + (s:ss) -> pure $ TestnetNodesWithOptions { optSpoNodes = s :| ss , optRelayNodes = map snd relays } - nodeSpec :: Parsec.Parsec String () (NodeRole, NodeOptions) + nodeSpec :: Parsec.Parsec String () (NodeRole, NodeWithOptions) nodeSpec = do role <- nodeRole bin <- optional $ char ':' *> nodeBinKV - pure (role, NodeOptions bin []) + pure (role, NodeWithOptions bin []) nodeRole :: Parsec.Parsec String () NodeRole nodeRole = diff --git a/cardano-testnet/src/Parsers/Run.hs b/cardano-testnet/src/Parsers/Run.hs index 145427db762..1d5971dadac 100644 --- a/cardano-testnet/src/Parsers/Run.hs +++ b/cardano-testnet/src/Parsers/Run.hs @@ -91,7 +91,7 @@ runCardanoOptions = \case let dirName = envPath fromEnvOptions unlessM (doesDirectoryExist dirName) $ error $ "The provided path does not exist or is not a directory: " <> dirName conf <- mkConfigAbs dirName - nodes <- readNodeOptionsFromEnv (unTmpAbsPath (tempAbsPath conf)) + nodes <- readNodesWithOptionsFromEnv (unTmpAbsPath (tempAbsPath conf)) runSimpleApp . runResourceT $ do logInfo $ "Starting testnet in environment: " <> display (tempAbsPath conf) void $ cardanoTestnet nodes fromEnvRuntimeOptions diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index da6b27eadc6..e5c89af4e5c 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -17,9 +17,9 @@ module Testnet.Start.Cardano , TestnetCreationOptions(..) , TestnetRuntimeOptions(..) , TestnetEnvOptions(..) - , TestnetNodeOptions(..) - , NodeOptions(..) - , cardanoDefaultTestnetNodeOptions + , TestnetNodesWithOptions(..) + , NodeWithOptions(..) + , cardanoDefaultTestnetNodesWithOptions , TestnetRuntime (..) @@ -28,7 +28,7 @@ module Testnet.Start.Cardano , createTestnetEnv , getDefaultAlonzoGenesis , getDefaultShelleyGenesis - , readNodeOptionsFromEnv + , readNodesWithOptionsFromEnv , retryOnAddressInUseError , liftToIntegration @@ -114,7 +114,7 @@ createTestnetEnv :: () createTestnetEnv creationOptions@TestnetCreationOptions { creationEra=asbe - , creationNodes=TestnetNodeOptions{optSpoNodes, optRelayNodes} + , creationNodes=TestnetNodesWithOptions{optSpoNodes, optRelayNodes} } Conf { genesisHashesPolicy @@ -237,12 +237,12 @@ cardanoTestnet => MonadResource m => MonadCatch m => MonadFail m - => TestnetNodeOptions -- ^ The nodes to start + => TestnetNodesWithOptions -- ^ The nodes to start -> TestnetRuntimeOptions -- ^ Runtime options -> Conf -- ^ Path to the test sandbox -> m TestnetRuntime cardanoTestnet - TestnetNodeOptions{optSpoNodes=cardanoSpoNodes, optRelayNodes=cardanoRelayNodes} + TestnetNodesWithOptions{optSpoNodes=cardanoSpoNodes, optRelayNodes=cardanoRelayNodes} TestnetRuntimeOptions { runtimeEnableNewEpochStateLogging=enableNewEpochStateLogging , runtimeEnableRpc=cardanoEnableRpc @@ -323,7 +323,7 @@ cardanoTestnet let portNumbersMap = Map.fromList portNumbers - eTestnetNodes <- forConcurrently (zip [1..] allNodes) $ \(i, (isSpo, nodeOptions)) -> do + eTestnetNodes <- forConcurrently (zip [1..] allNodes) $ \(i, (isSpo, nodeWithOptions)) -> do port <- case Map.lookup i portNumbersMap of Just p -> pure p Nothing -> throwString $ "Port not found for node " <> show i @@ -365,14 +365,14 @@ cardanoTestnet pure (Just keys, kesSourceCliArg <> shelleyCliArgs <> byronCliArgs) eRuntime <- runExceptT . retryOnAddressInUseError $ - startNode (TmpAbsolutePath tmpAbsPath) nodeName testnetDefaultIpv4Address port testnetMagic (nodeBin nodeOptions) $ + startNode (TmpAbsolutePath tmpAbsPath) nodeName testnetDefaultIpv4Address port testnetMagic (nodeBin nodeWithOptions) $ [ "run" , "--config", nodeConfigFile , "--topology", nodeDataDir "topology.json" , "--database-path", nodeDataDir "db" ] <> spoNodeCliArgs - <> nodeExtraCliArgs nodeOptions + <> nodeExtraCliArgs nodeWithOptions <> ["--grpc-enable" | RpcEnabled <- [cardanoEnableRpc]] pure $ eRuntime <&> \rt -> rt{poolKeys=mKeys} @@ -514,8 +514,8 @@ retryOnAddressInUseError act = withFrozenCallStack $ go maximumTimeout retryTime -- and checks @pools-keys/@ to classify each as SPO or relay. -- Validates that nodes are consecutively numbered starting from 1, -- and that all SPO nodes come before relay nodes. -readNodeOptionsFromEnv :: HasCallStack => MonadIO m => FilePath -> m TestnetNodeOptions -readNodeOptionsFromEnv envDir = do +readNodesWithOptionsFromEnv :: HasCallStack => MonadIO m => FilePath -> m TestnetNodesWithOptions +readNodesWithOptionsFromEnv envDir = do entries <- liftIO $ IO.listDirectory (envDir "node-data") let nodeNums = sort $ mapMaybe parseNodeNum entries when (null nodeNums) $ @@ -533,7 +533,7 @@ readNodeOptionsFromEnv envDir = do spoOpts <- mapM readNodeOpt [1 .. nSpos] relayOpts <- mapM readNodeOpt [nSpos + 1 .. length nodeNums] case spoOpts of - (s:ss) -> pure $ TestnetNodeOptions { optSpoNodes = s :| ss, optRelayNodes = relayOpts } + (s:ss) -> pure $ TestnetNodesWithOptions { optSpoNodes = s :| ss, optRelayNodes = relayOpts } [] -> throwString "No SPO node directories found in environment" where parseNodeNum s = do @@ -541,7 +541,7 @@ readNodeOptionsFromEnv envDir = do readMaybe rest :: Maybe Int readNodeOpt i = do bin <- readNodeBinFromEnvFile (envDir defaultNodeEnvFile i) - pure $ NodeOptions bin [] + pure $ NodeWithOptions bin [] data NodeEnv = NodeEnv { nodeBinary :: FilePath diff --git a/cardano-testnet/src/Testnet/Start/Types.hs b/cardano-testnet/src/Testnet/Start/Types.hs index 0df1692911f..c53ce344d9c 100644 --- a/cardano-testnet/src/Testnet/Start/Types.hs +++ b/cardano-testnet/src/Testnet/Start/Types.hs @@ -31,9 +31,9 @@ module Testnet.Start.Types , UpdateTimestamps(..) , TestnetOnChainParams(..) , mainnetParamsRequest - , TestnetNodeOptions(..) - , NodeOptions(..) - , cardanoDefaultTestnetNodeOptions + , TestnetNodesWithOptions(..) + , NodeWithOptions(..) + , cardanoDefaultTestnetNodesWithOptions , GenesisOptions(..) , UserProvidedData(..) , UserProvidedGeneses(..) @@ -176,7 +176,7 @@ data RpcSupport -- 'Testnet.Start.Cardano.createAndRunTestnet' in tests. data TestnetCreationOptions = TestnetCreationOptions { -- | Options controlling how many nodes to create and of which type. - creationNodes :: TestnetNodeOptions + creationNodes :: TestnetNodesWithOptions , creationEra :: AnyShelleyBasedEra -- ^ The era to start at , creationMaxSupply :: Word64 -- ^ The amount of Lovelace you are starting your testnet with (forwarded to shelley genesis) -- TODO move me to GenesisOptions when https://github.com/IntersectMBO/cardano-cli/pull/874 makes it to cardano-node @@ -187,7 +187,7 @@ data TestnetCreationOptions = TestnetCreationOptions instance Default TestnetCreationOptions where def = TestnetCreationOptions - { creationNodes = cardanoDefaultTestnetNodeOptions + { creationNodes = cardanoDefaultTestnetNodesWithOptions , creationEra = AnyShelleyBasedEra ShelleyBasedEraConway , creationMaxSupply = 100_000_020_000_000 , creationNumDReps = 3 @@ -259,16 +259,16 @@ instance Default GenesisOptions where } -- | Configuration specific to each node -data NodeOptions = NodeOptions +data NodeWithOptions = NodeWithOptions { nodeBin :: Maybe FilePath -- ^ Path to the @cardano-node@ binary to use for running this node. 'Nothing' uses the default resolution mechanism. , nodeExtraCliArgs :: [String] -- ^ Extra CLI arguments passed to @cardano-node run@ } deriving (Eq, Show) -- | Specifies the nodes to create for the testnet, split by role (SPO and relay). -- SPO nodes participate in block production. Relay nodes only forward blocks. -data TestnetNodeOptions = TestnetNodeOptions - { optSpoNodes :: NonEmpty NodeOptions -- ^ SPO (stake pool operator) nodes. Must have at least one. - , optRelayNodes :: [NodeOptions] -- ^ Relay (non-producing) nodes +data TestnetNodesWithOptions = TestnetNodesWithOptions + { optSpoNodes :: NonEmpty NodeWithOptions -- ^ SPO (stake pool operator) nodes. Must have at least one. + , optRelayNodes :: [NodeWithOptions] -- ^ Relay (non-producing) nodes } deriving (Eq, Show) -- | Type used to track whether the user is providing its data (node configuration file path, genesis file, etc.) @@ -281,11 +281,11 @@ data UserProvidedData a = instance Default (UserProvidedData a) where def = NoUserProvidedData -cardanoDefaultTestnetNodeOptions :: TestnetNodeOptions -cardanoDefaultTestnetNodeOptions = TestnetNodeOptions - { optSpoNodes = NodeOptions Nothing [] :| [] - , optRelayNodes = [ NodeOptions Nothing [] - , NodeOptions Nothing [] +cardanoDefaultTestnetNodesWithOptions :: TestnetNodesWithOptions +cardanoDefaultTestnetNodesWithOptions = TestnetNodesWithOptions + { optSpoNodes = NodeWithOptions Nothing [] :| [] + , optRelayNodes = [ NodeWithOptions Nothing [] + , NodeWithOptions Nothing [] ] } diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs index d57a66ef90b..176c464ca71 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Cli/LeadershipSchedule.hs @@ -71,10 +71,10 @@ hprop_leadershipSchedule = integrationRetryWorkspace 2 "leadership-schedule" $ \ cTestnetOptions = def { creationEra = asbe , creationNodes = - TestnetNodeOptions - { optSpoNodes = NodeOptions Nothing [] :| - [ NodeOptions Nothing [] - , NodeOptions Nothing [] + TestnetNodesWithOptions + { optSpoNodes = NodeWithOptions Nothing [] :| + [ NodeWithOptions Nothing [] + , NodeWithOptions Nothing [] ] , optRelayNodes = [] } diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs index e19f38c8c59..7add69da333 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitutionSPO.hs @@ -59,10 +59,10 @@ hprop_ledger_events_propose_new_constitution_spo = integrationRetryWorkspace 2 " creationOptions = def { creationEra = AnyShelleyBasedEra sbe , creationNodes = - TestnetNodeOptions - { optSpoNodes = NodeOptions Nothing [] :| - [ NodeOptions Nothing [] - , NodeOptions Nothing [] + TestnetNodesWithOptions + { optSpoNodes = NodeWithOptions Nothing [] :| + [ NodeWithOptions Nothing [] + , NodeWithOptions Nothing [] ] , optRelayNodes = [] } diff --git a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs index 7fd79f6423f..4e19f302e6e 100644 --- a/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs +++ b/cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Node/Shutdown.hs @@ -208,8 +208,8 @@ hprop_shutdownOnSlotSynced = integrationRetryWorkspace 2 "shutdown-on-slot-synce slotLen = 0.1 let creationOptions = def { creationNodes = - TestnetNodeOptions - { optSpoNodes = NodeOptions Nothing ["--shutdown-on-slot-synced", show maxSlot] :| [] + TestnetNodesWithOptions + { optSpoNodes = NodeWithOptions Nothing ["--shutdown-on-slot-synced", show maxSlot] :| [] , optRelayNodes = [] } , creationGenesisOptions = def From ffe73e82ce497143f6f242eb2550bae22a215695 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 8 May 2026 21:42:16 +0200 Subject: [PATCH 9/9] Move low level code to `RunIO.hs` --- cardano-testnet/src/Testnet/Process/RunIO.hs | 30 ++++++++++++++++++-- cardano-testnet/src/Testnet/Runtime.hs | 4 +-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cardano-testnet/src/Testnet/Process/RunIO.hs b/cardano-testnet/src/Testnet/Process/RunIO.hs index 719e2eeb832..fd664db0a64 100644 --- a/cardano-testnet/src/Testnet/Process/RunIO.hs +++ b/cardano-testnet/src/Testnet/Process/RunIO.hs @@ -12,6 +12,7 @@ module Testnet.Process.RunIO , procNode , procKesAgent , execKesAgentControl_ + , procCustom , procFlex , liftIOAnnotated ) where @@ -144,8 +145,7 @@ execFlexAny' execConfig pkgBin envBin arguments = GHC.withFrozenCallStack $ do cp <- procFlex' execConfig pkgBin envBin arguments liftIOAnnotated $ IO.readCreateProcessWithExitCode cp "" - - +-- | Like 'procFlex', but takes an explicit 'ExecConfig' instead of using 'defaultExecConfig'. procFlex' :: HasCallStack => MonadIO m @@ -160,7 +160,20 @@ procFlex' -- ^ Captured stdout procFlex' execConfig pkg binaryEnv arguments = GHC.withFrozenCallStack $ do bin <- binFlex pkg binaryEnv - return (IO.proc bin arguments) + procCustom' execConfig bin arguments + +-- | Build a 'CreateProcess' from an already-resolved binary path, arguments, and 'ExecConfig'. +procCustom' + :: (HasCallStack) + => MonadIO m + => ExecConfig + -> FilePath + -- ^ Path to the binary + -> [String] + -- ^ Arguments to the CLI command + -> m CreateProcess +procCustom' execConfig bin arguments = GHC.withFrozenCallStack $ + pure (IO.proc bin arguments) { IO.env = getLast $ execConfigEnv execConfig , IO.cwd = getLast $ execConfigCwd execConfig -- this allows sending signals to the created processes, without killing the test-suite process @@ -311,6 +324,17 @@ procFlex -- ^ Captured stdout procFlex = procFlex' defaultExecConfig +-- | Like 'procFlex', but takes an explicit binary path instead of resolving +-- via package name and environment variable. +procCustom + :: (HasCallStack) + => FilePath + -- ^ Path to the binary + -> [String] + -- ^ Arguments to the CLI command + -> RIO env CreateProcess +procCustom = procCustom' defaultExecConfig + -- This will also catch async exceptions as well. liftIOAnnotated :: (HasCallStack, MonadIO m) => IO a -> m a liftIOAnnotated action = GHC.withFrozenCallStack $ diff --git a/cardano-testnet/src/Testnet/Runtime.hs b/cardano-testnet/src/Testnet/Runtime.hs index 3458d48891c..e271f264d42 100644 --- a/cardano-testnet/src/Testnet/Runtime.hs +++ b/cardano-testnet/src/Testnet/Runtime.hs @@ -57,7 +57,7 @@ import Cardano.Node.Testnet.Paths (defaultSocketName) import qualified Testnet.Ping as Ping import Testnet.Process.Run (ProcessError (..), initiateProcess) import Testnet.Process.RunIO (execCli_, execKesAgentControl_, liftIOAnnotated, - procKesAgent, procNode) + procCustom, procKesAgent, procNode) import Testnet.Types (TestnetKesAgent (..), TestnetNode (..), TestnetRuntime (configurationFile), showIpv4Address, testnetSprockets) @@ -161,7 +161,7 @@ startNode tp node ipv4 port _testnetMagic mNodeBin nodeCmd = GHC.withFrozenCallS nodeProcess <- newExceptT . fmap (first ExecutableRelatedFailure) . try $ runRIO () $ case mNodeBin of Nothing -> procNode completeNodeCmd - Just bin -> pure (IO.proc bin completeNodeCmd){ IO.create_group = True } + Just bin -> procCustom bin completeNodeCmd -- The port number if it is obtained using 'H.randomPort', it is firstly bound to and then closed. The closing -- and release in the operating system is done asynchronously and can be slow. Here we wait until the port