From b2f32cc3d86e5fec4ab374836007770ffcf174c2 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Thu, 7 May 2026 01:24:32 +0200 Subject: [PATCH 1/3] Enforce SPOs come first and there is at least one --- .../test/Spec/Chairman/Cardano.hs | 3 +- cardano-testnet/src/Cardano/Testnet.hs | 1 + cardano-testnet/src/Parsers/Cardano.hs | 13 +-- cardano-testnet/src/Testnet/Start/Cardano.hs | 83 +++++++++---------- cardano-testnet/src/Testnet/Start/Types.hs | 43 +++++----- .../Testnet/Test/Cli/LeadershipSchedule.hs | 11 ++- .../Test/Gov/ProposeNewConstitutionSPO.hs | 11 ++- .../Cardano/Testnet/Test/Node/Shutdown.hs | 5 +- 8 files changed, 88 insertions(+), 82 deletions(-) diff --git a/cardano-node-chairman/test/Spec/Chairman/Cardano.hs b/cardano-node-chairman/test/Spec/Chairman/Cardano.hs index 99ff8f690b3..e443ea2bd07 100644 --- a/cardano-node-chairman/test/Spec/Chairman/Cardano.hs +++ b/cardano-node-chairman/test/Spec/Chairman/Cardano.hs @@ -8,7 +8,6 @@ module Spec.Chairman.Cardano where import Cardano.Testnet import Data.Default.Class -import Data.List.NonEmpty (NonEmpty ((:|))) import Testnet.Property.Util (integrationRetryWorkspace) import qualified Hedgehog as H @@ -20,7 +19,7 @@ hprop_chairman :: H.Property hprop_chairman = integrationRetryWorkspace 2 "cardano-chairman" $ \tempAbsPath' -> H.runWithDefaultWatchdog_ $ do conf <- mkConf tempAbsPath' - let creationOptions = def{ creationNodes = SpoNodeOptions [] :| [RelayNodeOptions [], RelayNodeOptions []] } + let creationOptions = def{ creationNodes = cardanoDefaultTestnetNodeOptions } 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 61519e23c22..4637a00ecef 100644 --- a/cardano-testnet/src/Cardano/Testnet.hs +++ b/cardano-testnet/src/Cardano/Testnet.hs @@ -14,6 +14,7 @@ module Cardano.Testnet ( TestnetRuntimeOptions(..), TestnetEnvOptions(..), RpcSupport(..), + TestnetNodeOptions(..), NodeOption(..), cardanoDefaultTestnetNodeOptions, getDefaultAlonzoGenesis, diff --git a/cardano-testnet/src/Parsers/Cardano.hs b/cardano-testnet/src/Parsers/Cardano.hs index 398538892ed..e80a52636dd 100644 --- a/cardano-testnet/src/Parsers/Cardano.hs +++ b/cardano-testnet/src/Parsers/Cardano.hs @@ -105,18 +105,21 @@ pKesSource = OA.flag UseKesKeyFile UseKesSocket <> OA.showDefault ) -pTestnetNodeOptions :: Parser (NonEmpty NodeOption) +pTestnetNodeOptions :: Parser TestnetNodeOptions pTestnetNodeOptions = - -- If `--num-pool-nodes N` is present, return N nodes with option `SpoNodeOptions []`. - -- Otherwise, return `cardanoDefaultTestnetNodeOptions` - fmap (maybe cardanoDefaultTestnetNodeOptions (\num -> defaultSpoOptions :| L.replicate (num - 1) defaultSpoOptions)) <$> + 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" ) where - defaultSpoOptions = SpoNodeOptions [] + defaultSpoOption = NodeOption [] + + mkPoolNodes num = TestnetNodeOptions + { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption + , optRelayNodes = [] + } ensureAtLeastOne :: OA.ReadM Int ensureAtLeastOne = readerAsk >>= \arg -> diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index 4d6be346fed..bb403084bf1 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -17,6 +17,7 @@ module Testnet.Start.Cardano , TestnetCreationOptions(..) , TestnetRuntimeOptions(..) , TestnetEnvOptions(..) + , TestnetNodeOptions(..) , NodeOption(..) , cardanoDefaultTestnetNodeOptions @@ -92,16 +93,6 @@ import RIO.State (put) import UnliftIO.Async import UnliftIO.Exception (stringException) --- | There are certain conditions that need to be met in order to run --- a valid node cluster. -testMinimumConfigurationRequirements :: () - => HasCallStack - => MonadIO m - => NonEmpty NodeOption -> m () -testMinimumConfigurationRequirements nodes = withFrozenCallStack $ do - unless (any isSpoNodeOptions nodes) $ do - throwString "Need at least one SPO node to produce blocks, but got none." - liftToIntegration :: HasCallStack => RIO ResourceMap a -> H.Integration a liftToIntegration r = do rMap <- lift $ lift getInternalState @@ -118,15 +109,13 @@ createTestnetEnv :: () createTestnetEnv creationOptions@TestnetCreationOptions { creationEra=asbe - , creationNodes + , creationNodes=TestnetNodeOptions{optSpoNodes, optRelayNodes} } Conf { genesisHashesPolicy , tempAbsPath=TmpAbsolutePath tmpAbsPath } = do - testMinimumConfigurationRequirements creationNodes - AnyShelleyBasedEra sbe <- pure asbe _ <- createSPOGenesisAndFiles @@ -141,13 +130,16 @@ createTestnetEnv liftIOAnnotated . LBS.writeFile configurationFile $ A.encodePretty $ Object config - portNumbers <- forM (NEL.zip (1 :| [2..]) creationNodes) + let allNodes = NEL.toList optSpoNodes ++ optRelayNodes + numberedNodes = zip [1..] allNodes + nodeIds = map fst numberedNodes + + portNumbers <- forM numberedNodes (\(i, _nodeOption) -> (i,) <$> H.randomPort testnetDefaultIpv4Address) - let portNumbersMap = Map.fromList (NEL.toList portNumbers) + let portNumbersMap = Map.fromList portNumbers -- Create network topology and write port files - let nodeIds = fst <$> NEL.zip (1 :| [2..]) creationNodes forM_ nodeIds $ \i -> do let nodeDataDir = tmpAbsPath Defaults.defaultNodeDataDir i liftIOAnnotated $ IO.createDirectoryIfMissing True nodeDataDir @@ -157,7 +149,7 @@ createTestnetEnv Just port -> liftIOAnnotated $ writeFile (tmpAbsPath defaultPortFile i) (show port) Nothing -> throwString $ "Port not found for node " <> show i - producers <- mapM (idToRemoteAddressP2P portNumbersMap) $ NodeId <$> NEL.filter (/= i) nodeIds + producers <- mapM (idToRemoteAddressP2P portNumbersMap) $ NodeId <$> filter (/= i) nodeIds let topology = Defaults.defaultP2PTopology producers liftIOAnnotated . LBS.writeFile (nodeDataDir "topology.json") $ A.encodePretty topology @@ -232,12 +224,12 @@ cardanoTestnet => MonadResource m => MonadCatch m => MonadFail m - => NonEmpty NodeOption -- ^ The nodes to start + => TestnetNodeOptions -- ^ The nodes to start -> TestnetRuntimeOptions -- ^ Runtime options -> Conf -- ^ Path to the test sandbox -> m TestnetRuntime cardanoTestnet - cardanoNodes + TestnetNodeOptions{optSpoNodes=cardanoSpoNodes, optRelayNodes=cardanoRelayNodes} TestnetRuntimeOptions { runtimeEnableNewEpochStateLogging=enableNewEpochStateLogging , runtimeEnableRpc=cardanoEnableRpc @@ -247,8 +239,8 @@ cardanoTestnet { tempAbsPath=TmpAbsolutePath tmpAbsPath , updateTimestamps } = do - testMinimumConfigurationRequirements cardanoNodes - let nPools = NumPools $ length $ NEL.filter isSpoNodeOptions cardanoNodes + let nPools = NumPools $ NEL.length cardanoSpoNodes + allNodes = map (True,) (NEL.toList cardanoSpoNodes) ++ map (False,) cardanoRelayNodes nodeConfigFile = tmpAbsPath defaultConfigFile byronGenesisFile = tmpAbsPath "byron-genesis.json" shelleyGenesisFile = tmpAbsPath "shelley-genesis.json" @@ -279,7 +271,7 @@ cardanoTestnet } -- Read port numbers from disk (written by createTestnetEnv) - portNumbers <- forM (NEL.zip (1 :| [2..]) cardanoNodes) $ \(i, _nodeOption) -> do + portNumbers <- forM (zip [1..] allNodes) $ \(i, _) -> do let nodeDataDir = tmpAbsPath Defaults.defaultNodeDataDir i portPath = tmpAbsPath defaultPortFile i portStr <- liftIOAnnotated $ readFile portPath @@ -316,19 +308,16 @@ cardanoTestnet let shelleyGenesis' = shelleyGenesis{sgSystemStart = startTime} liftIOAnnotated . LBS.writeFile shelleyGenesisFile $ A.encodePretty shelleyGenesis' - let portNumbersMap = Map.fromList (NEL.toList portNumbers) + let portNumbersMap = Map.fromList portNumbers - eTestnetNodes <- forConcurrently (NEL.zip (1 :| [2..]) cardanoNodes) $ \(i, nodeOptions) -> do + eTestnetNodes <- forConcurrently (zip [1..] allNodes) $ \(i, (isSpo, nodeOptions)) -> do port <- case Map.lookup i portNumbersMap of Just p -> pure p Nothing -> throwString $ "Port not found for node " <> show i let nodeName = Defaults.defaultNodeName i nodeDataDir = tmpAbsPath Defaults.defaultNodeDataDir i nodePoolKeysDir = tmpAbsPath Defaults.defaultSpoKeysDir i - (mKeys, spoNodeCliArgs) <- - case nodeOptions of - RelayNodeOptions{} -> pure (Nothing, []) - SpoNodeOptions{} -> do + (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 @@ -370,11 +359,11 @@ cardanoTestnet , "--database-path", nodeDataDir "db" ] <> spoNodeCliArgs - <> extraCliArgs nodeOptions + <> nodeExtraCliArgs nodeOptions <> ["--grpc-enable" | RpcEnabled <- [cardanoEnableRpc]] pure $ eRuntime <&> \rt -> rt{poolKeys=mKeys} - let (failedNodes, testnetNodes') = partitionEithers (NEL.toList eTestnetNodes) + let (failedNodes, testnetNodes') = partitionEithers eTestnetNodes unless (null failedNodes) $ do throwString $ "Some nodes failed to start:\n" ++ show (vsep $ prettyError <$> failedNodes) @@ -417,9 +406,6 @@ cardanoTestnet pure runtime where - extraCliArgs = \case - SpoNodeOptions args -> args - RelayNodeOptions args -> args -- TODO: This should come from the configuration! makePathsAbsolute :: (Element a ~ FilePath, MonoFunctor a) => a -> a makePathsAbsolute = omap (tmpAbsPath ) @@ -511,19 +497,32 @@ retryOnAddressInUseError act = withFrozenCallStack $ go maximumTimeout retryTime retryTimeout = 5 -- | Read node options from an existing testnet environment directory. --- Scans @node-data/@ for node directories and checks @pools-keys/@ to --- classify each node as SPO or relay. -readNodeOptionsFromEnv :: MonadIO m => FilePath -> m (NonEmpty NodeOption) +-- Scans @node-data/@ for node directories numbered @node1, node2, ...@ +-- 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 :: MonadIO m => FilePath -> m TestnetNodeOptions readNodeOptionsFromEnv envDir = do entries <- liftIO $ IO.listDirectory (envDir "node-data") let nodeNums = sort $ mapMaybe parseNodeNum entries - case nodeNums of - [] -> throwString "No node directories found in environment" - (n:ns) -> mapM classifyNode (n :| ns) + when (null nodeNums) $ + throwString "No node directories found in environment" + when (nodeNums /= [1 .. length nodeNums]) $ + throwString $ "Node directories are not consecutively numbered from 1: " <> show nodeNums + isSpoFlags <- forM nodeNums $ \i -> + liftIO $ IO.doesDirectoryExist (envDir Defaults.defaultSpoKeysDir i) + let (spoFlags, relayFlags) = span id isSpoFlags + unless (all not relayFlags) $ + throwString "SPO nodes must come before relay nodes in the environment" + when (null spoFlags) $ + throwString "No SPO node directories found in environment" + let nSpos = length spoFlags + let spoOpts = map (const (NodeOption [])) [1 .. nSpos] + relayOpts = map (const (NodeOption [])) [nSpos + 1 .. length nodeNums] + case spoOpts of + (s:ss) -> pure $ TestnetNodeOptions { optSpoNodes = s :| ss, optRelayNodes = relayOpts } + [] -> throwString "No SPO node directories found in environment" where parseNodeNum s = do rest <- stripPrefix "node" s readMaybe rest :: Maybe Int - classifyNode i = do - hasPools <- liftIO $ IO.doesDirectoryExist (envDir Defaults.defaultSpoKeysDir i) - pure $ if hasPools then SpoNodeOptions [] else RelayNodeOptions [] diff --git a/cardano-testnet/src/Testnet/Start/Types.hs b/cardano-testnet/src/Testnet/Start/Types.hs index 8dbeeab4dbb..fd1dea9e2e3 100644 --- a/cardano-testnet/src/Testnet/Start/Types.hs +++ b/cardano-testnet/src/Testnet/Start/Types.hs @@ -31,9 +31,8 @@ module Testnet.Start.Types , UpdateTimestamps(..) , TestnetOnChainParams(..) , mainnetParamsRequest + , TestnetNodeOptions(..) , NodeOption(..) - , isSpoNodeOptions - , isRelayNodeOptions , cardanoDefaultTestnetNodeOptions , GenesisOptions(..) , UserProvidedData(..) @@ -177,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 :: NonEmpty NodeOption + creationNodes :: TestnetNodeOptions , 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 @@ -225,11 +224,11 @@ newtype InputNodeConfigFile = InputNodeConfigFile FilePath creationNumPools :: TestnetCreationOptions -> NumPools creationNumPools TestnetCreationOptions{creationNodes} = - NumPools $ length $ NEL.filter isSpoNodeOptions creationNodes + NumPools $ NEL.length $ optSpoNodes creationNodes creationNumRelays :: TestnetCreationOptions -> NumRelays creationNumRelays TestnetCreationOptions{creationNodes} = - NumRelays $ length $ NEL.filter isRelayNodeOptions creationNodes + NumRelays $ length $ optRelayNodes creationNodes -- | Number of stake pool nodes newtype NumPools = NumPools Int @@ -259,12 +258,14 @@ instance Default GenesisOptions where , genesisActiveSlotsCoeff = 0.05 } --- | Whether a node should be an SPO or just a relay. --- The '@String' arguments will be appended to the default options when starting the node. -data NodeOption - = SpoNodeOptions [String] - | RelayNodeOptions [String] - deriving (Eq, Show) +newtype NodeOption = NodeOption + { nodeExtraCliArgs :: [String] + } deriving (Eq, Show) + +data TestnetNodeOptions = TestnetNodeOptions + { optSpoNodes :: NonEmpty NodeOption + , optRelayNodes :: [NodeOption] + } deriving (Eq, Show) -- | Type used to track whether the user is providing its data (node configuration file path, genesis file, etc.) -- or whether it needs to be programmatically generated by @cardanoTestnet@ and friends. @@ -276,19 +277,13 @@ data UserProvidedData a = instance Default (UserProvidedData a) where def = NoUserProvidedData -isSpoNodeOptions :: NodeOption -> Bool -isSpoNodeOptions SpoNodeOptions{} = True -isSpoNodeOptions RelayNodeOptions{} = False - -isRelayNodeOptions :: NodeOption -> Bool -isRelayNodeOptions SpoNodeOptions{} = False -isRelayNodeOptions RelayNodeOptions{} = True - -cardanoDefaultTestnetNodeOptions :: NonEmpty NodeOption -cardanoDefaultTestnetNodeOptions = - SpoNodeOptions [] :| [ RelayNodeOptions [] - , RelayNodeOptions [] - ] +cardanoDefaultTestnetNodeOptions :: TestnetNodeOptions +cardanoDefaultTestnetNodeOptions = TestnetNodeOptions + { optSpoNodes = NodeOption [] :| [] + , optRelayNodes = [ NodeOption [] + , NodeOption [] + ] + } data NodeLoggingFormat = NodeLoggingFormatAsJson 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 1fa760f4f90..d7e1d880fb0 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,13 @@ hprop_leadershipSchedule = integrationRetryWorkspace 2 "leadership-schedule" $ \ cTestnetOptions = def { creationEra = asbe , creationNodes = - SpoNodeOptions [] :| - [ SpoNodeOptions [] - , SpoNodeOptions [] - ] + TestnetNodeOptions + { optSpoNodes = NodeOption [] :| + [ NodeOption [] + , NodeOption [] + ] + , optRelayNodes = [] + } } eraString = eraToString sbe 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 a5950dd32d0..b01bddc7a9c 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,13 @@ hprop_ledger_events_propose_new_constitution_spo = integrationRetryWorkspace 2 " creationOptions = def { creationEra = AnyShelleyBasedEra sbe , creationNodes = - SpoNodeOptions [] :| - [ SpoNodeOptions [] - , SpoNodeOptions [] - ] + TestnetNodeOptions + { optSpoNodes = NodeOption [] :| + [ NodeOption [] + , NodeOption [] + ] + , optRelayNodes = [] + } , creationGenesisOptions = def { genesisEpochLength = 100 } } 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 bc4e34bc852..50e24a483d8 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,7 +208,10 @@ hprop_shutdownOnSlotSynced = integrationRetryWorkspace 2 "shutdown-on-slot-synce slotLen = 0.1 let creationOptions = def { creationNodes = - SpoNodeOptions ["--shutdown-on-slot-synced", show maxSlot] :| [] + TestnetNodeOptions + { optSpoNodes = NodeOption ["--shutdown-on-slot-synced", show maxSlot] :| [] + , optRelayNodes = [] + } , creationGenesisOptions = def { genesisEpochLength = epochLength , genesisSlotLength = slotLen From 86e23ba3b3b5cdeeab5e460ca4ba702db34ad4eb Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Thu, 7 May 2026 17:28:18 +0200 Subject: [PATCH 2/3] Apply suggestions by @carbolymer Co-authored-by: Mateusz Galazyn <228866+carbolymer@users.noreply.github.com> --- cardano-testnet/src/Cardano/Testnet.hs | 2 +- cardano-testnet/src/Parsers/Cardano.hs | 2 +- cardano-testnet/src/Testnet/Start/Cardano.hs | 8 ++++---- cardano-testnet/src/Testnet/Start/Types.hs | 19 +++++++++++-------- .../Testnet/Test/Cli/LeadershipSchedule.hs | 6 +++--- .../Test/Gov/ProposeNewConstitutionSPO.hs | 6 +++--- .../Cardano/Testnet/Test/Node/Shutdown.hs | 2 +- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/cardano-testnet/src/Cardano/Testnet.hs b/cardano-testnet/src/Cardano/Testnet.hs index 4637a00ecef..b965252d776 100644 --- a/cardano-testnet/src/Cardano/Testnet.hs +++ b/cardano-testnet/src/Cardano/Testnet.hs @@ -15,7 +15,7 @@ module Cardano.Testnet ( TestnetEnvOptions(..), RpcSupport(..), TestnetNodeOptions(..), - NodeOption(..), + NodeOptions(..), cardanoDefaultTestnetNodeOptions, getDefaultAlonzoGenesis, getDefaultShelleyGenesis, diff --git a/cardano-testnet/src/Parsers/Cardano.hs b/cardano-testnet/src/Parsers/Cardano.hs index e80a52636dd..d93ec846982 100644 --- a/cardano-testnet/src/Parsers/Cardano.hs +++ b/cardano-testnet/src/Parsers/Cardano.hs @@ -114,7 +114,7 @@ pTestnetNodeOptions = <> OA.metavar "COUNT" ) where - defaultSpoOption = NodeOption [] + defaultSpoOption = NodeOptions [] mkPoolNodes num = TestnetNodeOptions { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption diff --git a/cardano-testnet/src/Testnet/Start/Cardano.hs b/cardano-testnet/src/Testnet/Start/Cardano.hs index bb403084bf1..0395bd1ba91 100644 --- a/cardano-testnet/src/Testnet/Start/Cardano.hs +++ b/cardano-testnet/src/Testnet/Start/Cardano.hs @@ -18,7 +18,7 @@ module Testnet.Start.Cardano , TestnetRuntimeOptions(..) , TestnetEnvOptions(..) , TestnetNodeOptions(..) - , NodeOption(..) + , NodeOptions(..) , cardanoDefaultTestnetNodeOptions , TestnetRuntime (..) @@ -501,7 +501,7 @@ 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 :: MonadIO m => FilePath -> m TestnetNodeOptions +readNodeOptionsFromEnv :: HasCallStack => MonadIO m => FilePath -> m TestnetNodeOptions readNodeOptionsFromEnv envDir = do entries <- liftIO $ IO.listDirectory (envDir "node-data") let nodeNums = sort $ mapMaybe parseNodeNum entries @@ -517,8 +517,8 @@ readNodeOptionsFromEnv envDir = do when (null spoFlags) $ throwString "No SPO node directories found in environment" let nSpos = length spoFlags - let spoOpts = map (const (NodeOption [])) [1 .. nSpos] - relayOpts = map (const (NodeOption [])) [nSpos + 1 .. length nodeNums] + let spoOpts = map (const (NodeOptions [])) [1 .. nSpos] + relayOpts = map (const (NodeOptions [])) [nSpos + 1 .. length nodeNums] case spoOpts of (s:ss) -> pure $ TestnetNodeOptions { optSpoNodes = s :| ss, optRelayNodes = relayOpts } [] -> throwString "No SPO node directories found in environment" diff --git a/cardano-testnet/src/Testnet/Start/Types.hs b/cardano-testnet/src/Testnet/Start/Types.hs index fd1dea9e2e3..c221f451480 100644 --- a/cardano-testnet/src/Testnet/Start/Types.hs +++ b/cardano-testnet/src/Testnet/Start/Types.hs @@ -32,7 +32,7 @@ module Testnet.Start.Types , TestnetOnChainParams(..) , mainnetParamsRequest , TestnetNodeOptions(..) - , NodeOption(..) + , NodeOptions(..) , cardanoDefaultTestnetNodeOptions , GenesisOptions(..) , UserProvidedData(..) @@ -258,13 +258,16 @@ instance Default GenesisOptions where , genesisActiveSlotsCoeff = 0.05 } -newtype NodeOption = NodeOption - { nodeExtraCliArgs :: [String] +-- | Configuration specific to each node +newtype NodeOptions = NodeOptions + { 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 NodeOption - , optRelayNodes :: [NodeOption] + { optSpoNodes :: NonEmpty NodeOptions -- ^ SPO (stake pool operator) nodes. Must have at least one. + , optRelayNodes :: [NodeOptions] -- ^ 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.) @@ -279,9 +282,9 @@ instance Default (UserProvidedData a) where cardanoDefaultTestnetNodeOptions :: TestnetNodeOptions cardanoDefaultTestnetNodeOptions = TestnetNodeOptions - { optSpoNodes = NodeOption [] :| [] - , optRelayNodes = [ NodeOption [] - , NodeOption [] + { optSpoNodes = NodeOptions [] :| [] + , optRelayNodes = [ NodeOptions [] + , NodeOptions [] ] } 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 d7e1d880fb0..91e98ad35ea 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 = NodeOption [] :| - [ NodeOption [] - , NodeOption [] + { optSpoNodes = NodeOptions [] :| + [ NodeOptions [] + , NodeOptions [] ] , 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 b01bddc7a9c..5e810cb287a 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 = NodeOption [] :| - [ NodeOption [] - , NodeOption [] + { optSpoNodes = NodeOptions [] :| + [ NodeOptions [] + , NodeOptions [] ] , 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 50e24a483d8..00408cfcdeb 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 = NodeOption ["--shutdown-on-slot-synced", show maxSlot] :| [] + { optSpoNodes = NodeOptions ["--shutdown-on-slot-synced", show maxSlot] :| [] , optRelayNodes = [] } , creationGenesisOptions = def From eb5f40e3e072a3bb7f5c6e00ceb478fbdca135d3 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Thu, 7 May 2026 01:30:14 +0200 Subject: [PATCH 3/3] Add changelog entry for node list split --- ...0507_012915_palas_split_nodes_list_into_spo_and_relay.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 cardano-testnet/changelog.d/20260507_012915_palas_split_nodes_list_into_spo_and_relay.md diff --git a/cardano-testnet/changelog.d/20260507_012915_palas_split_nodes_list_into_spo_and_relay.md b/cardano-testnet/changelog.d/20260507_012915_palas_split_nodes_list_into_spo_and_relay.md new file mode 100644 index 00000000000..9bdd75bb75b --- /dev/null +++ b/cardano-testnet/changelog.d/20260507_012915_palas_split_nodes_list_into_spo_and_relay.md @@ -0,0 +1,6 @@ +### Changed + +- Refactored `NodeOption` from a sum type into a record with a `TestnetNodeOptions` container + that enforces at the type level that SPO nodes come first and at least one is present. +- `readNodeOptionsFromEnv` now validates that node directories are consecutively numbered + and that SPOs come before relays.