Skip to content

Commit 9c20173

Browse files
authored
Merge pull request #6559 from IntersectMBO/testnet-specify-node-bin-per-node
cardano-testnet: Add `--nodes` flag for per-node binary configuration
2 parents 0af0a16 + 17045df commit 9c20173

20 files changed

Lines changed: 496 additions & 110 deletions

File tree

cardano-node-chairman/test/Spec/Chairman/Cardano.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ hprop_chairman :: H.Property
1919
hprop_chairman = integrationRetryWorkspace 2 "cardano-chairman" $ \tempAbsPath' -> H.runWithDefaultWatchdog_ $ do
2020
conf <- mkConf tempAbsPath'
2121

22-
let creationOptions = def{ creationNodes = cardanoDefaultTestnetNodeOptions }
22+
let creationOptions = def{ creationNodes = cardanoDefaultTestnetNodesWithOptions }
2323
allNodes <- testnetNodes <$> createAndRunTestnet creationOptions def conf
2424

2525
chairmanOver 120 50 conf allNodes

cardano-node/src/Cardano/Node/Testnet/Paths.hs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module Cardano.Node.Testnet.Paths
1515
, defaultSocketPath
1616
, defaultConfigFile
1717
, defaultPortFile
18+
, defaultNodeEnvFile
1819
) where
1920

2021
import System.FilePath ((</>))
@@ -62,3 +63,7 @@ defaultConfigFile = "configuration.yaml"
6263
-- | Relative path to a node's port file: @defaultNodeDataDir n </> "port"@
6364
defaultPortFile :: Int -> FilePath
6465
defaultPortFile n = defaultNodeDataDir n </> "port"
66+
67+
-- | Relative path to a node's env file: @defaultNodeDataDir n </> "env"@
68+
defaultNodeEnvFile :: Int -> FilePath
69+
defaultNodeEnvFile n = defaultNodeDataDir n </> "env"

cardano-testnet/cardano-testnet.cabal

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ library
8484
, network
8585
, network-mux
8686
, optparse-applicative-fork
87+
, parsec
8788
, ouroboros-network:{api, framework, ouroboros-network} ^>= 1.1
8889
, cardano-diffusion:{api, cardano-diffusion} ^>= 1.0
8990
, prettyprinter
@@ -135,7 +136,8 @@ library
135136
Testnet.TestQueryCmds
136137
Testnet.Types
137138

138-
other-modules: Parsers.Cardano
139+
exposed-modules: Parsers.Cardano
140+
other-modules:
139141
Parsers.Help
140142
Parsers.Version
141143
Testnet.TestEnumGenerator
@@ -240,6 +242,7 @@ test-suite cardano-testnet-test
240242
Cardano.Testnet.Test.Rpc.Transaction
241243
Cardano.Testnet.Test.Misc
242244
Cardano.Testnet.Test.Node.Shutdown
245+
Cardano.Testnet.Test.Parser
243246
Cardano.Testnet.Test.MainnetParams
244247
Cardano.Testnet.Test.SanityCheck
245248
Cardano.Testnet.Test.RunTestnet
@@ -287,6 +290,7 @@ test-suite cardano-testnet-test
287290
, regex-compat
288291
, rio
289292
, tasty ^>= 1.5
293+
, tasty-hedgehog
290294
, text
291295
, time
292296
, transformers
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
### Added
2+
3+
- Added `--nodes` flag to specify node roles (SPO/relay) and custom `cardano-node` binaries per node.
4+
Example: `--nodes spo,spo:node-bin=/path/to/bin,relay,relay`.
5+
6+
### Changed
7+
8+
- Renamed `NodeOptions` to `NodeWithOptions` and `TestnetNodeOptions` to `TestnetNodesWithOptions`
9+
(exported from `Testnet.Start.Types` and `Cardano.Testnet`). The new types include a `nodeBin` field for
10+
specifying a per-node `cardano-node` binary.

cardano-testnet/src/Cardano/Testnet.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ module Cardano.Testnet (
1414
TestnetRuntimeOptions(..),
1515
TestnetEnvOptions(..),
1616
RpcSupport(..),
17-
TestnetNodeOptions(..),
18-
NodeOptions(..),
19-
cardanoDefaultTestnetNodeOptions,
17+
TestnetNodesWithOptions(..),
18+
NodeWithOptions(..),
19+
cardanoDefaultTestnetNodesWithOptions,
2020
getDefaultAlonzoGenesis,
2121
getDefaultShelleyGenesis,
2222

cardano-testnet/src/Parsers/Cardano.hs

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module Parsers.Cardano
44
( cmdCardano
55
, cmdCreateEnv
6+
, parseNodeSpecs
67
) where
78

89
import Cardano.Api (AnyShelleyBasedEra (..))
@@ -13,6 +14,7 @@ import Cardano.Prelude (readMaybe)
1314
import Prelude
1415

1516
import Control.Applicative (optional, (<|>))
17+
import Control.Monad (unless)
1618
import Data.Default.Class (def)
1719
import qualified Data.List as L
1820
import Data.List.NonEmpty (NonEmpty ((:|)))
@@ -21,6 +23,10 @@ import Data.Word (Word64)
2123
import Options.Applicative (CommandFields, Mod, Parser)
2224
import qualified Options.Applicative as OA
2325
import Options.Applicative.Types (readerAsk)
26+
import Text.Parsec (char, many1, noneOf,
27+
sepBy1, string, try, (<?>), parse, eof, notFollowedBy)
28+
import qualified Text.Parsec as Parsec
29+
import qualified Text.Parsec.String as Parsec
2430

2531
import Testnet.Defaults (defaultEra)
2632
import Testnet.Start.Cardano
@@ -55,7 +61,7 @@ pFromEnv = TestnetEnvOptions
5561

5662
pCreationOptions :: Parser TestnetCreationOptions
5763
pCreationOptions = TestnetCreationOptions
58-
<$> pTestnetNodeOptions
64+
<$> pTestnetNodesWithOptions
5965
<*> pure (AnyShelleyBasedEra defaultEra)
6066
<*> pMaxLovelaceSupply
6167
<*> pNumDReps
@@ -105,28 +111,99 @@ pKesSource = OA.flag UseKesKeyFile UseKesSocket
105111
<> OA.showDefault
106112
)
107113

108-
pTestnetNodeOptions :: Parser TestnetNodeOptions
109-
pTestnetNodeOptions =
110-
fmap (maybe cardanoDefaultTestnetNodeOptions mkPoolNodes) <$>
111-
optional $ OA.option ensureAtLeastOne
112-
( OA.long "num-pool-nodes"
113-
<> OA.help "Number of pool nodes. Note this uses a default node configuration for all nodes."
114-
<> OA.metavar "COUNT"
115-
)
114+
pTestnetNodesWithOptions :: Parser TestnetNodesWithOptions
115+
pTestnetNodesWithOptions =
116+
pNodes <|> pNumPoolNodes <|> pure cardanoDefaultTestnetNodesWithOptions
116117
where
117-
defaultSpoOption = NodeOptions []
118-
119-
mkPoolNodes num = TestnetNodeOptions
120-
{ optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption
121-
, optRelayNodes = []
122-
}
118+
pNumPoolNodes :: Parser TestnetNodesWithOptions
119+
pNumPoolNodes =
120+
(\num -> TestnetNodesWithOptions { optSpoNodes = defaultSpoOption :| L.replicate (num - 1) defaultSpoOption, optRelayNodes = [] }) <$>
121+
OA.option ensureAtLeastOne
122+
( OA.long "num-pool-nodes"
123+
<> OA.help "Number of pool nodes. Note this uses a default node configuration for all nodes."
124+
<> OA.metavar "COUNT"
125+
)
126+
defaultSpoOption = NodeWithOptions Nothing []
123127

124128
ensureAtLeastOne :: OA.ReadM Int
125129
ensureAtLeastOne = readerAsk >>= \arg ->
126130
case readMaybe arg of
127131
Just n | n >= 1 -> pure n
128132
_ -> fail "Need at least one SPO node to produce blocks, but got none."
129133

134+
pNodes :: Parser TestnetNodesWithOptions
135+
pNodes = OA.option readNodeSpecs
136+
( OA.long "nodes"
137+
<> OA.help "Comma-separated node specifications. SPO nodes must come before relay nodes. \
138+
\Each spec is a role (spo or relay) optionally followed by :node-bin=<path>. \
139+
\If the path contains commas, colons, double quotes, or backslashes, wrap it \
140+
\in double quotes and escape any literal double quotes as \\\" and backslashes \
141+
\as \\\\ within. To prevent bash from consuming the double quotes, enclose the \
142+
\whole argument in single quotes. \
143+
\Examples: --nodes spo,spo:node-bin=/path/to/bin,relay,relay | \
144+
\--nodes 'spo:node-bin=\"/path,with:commas\",relay'"
145+
<> OA.metavar "SPEC[,SPEC...]"
146+
)
147+
148+
readNodeSpecs :: OA.ReadM TestnetNodesWithOptions
149+
readNodeSpecs = readerAsk >>= either (fail . show) pure . parseNodeSpecs
150+
151+
-- | Parse a @--nodes@ argument string into 'TestnetNodesWithOptions'.
152+
--
153+
-- SPO nodes are required to appear before relay nodes because:
154+
--
155+
-- 1. The testnet configuration assigns node directories by position (node-spo1,
156+
-- node-spo2, …, relay1, relay2, …). Allowing arbitrary ordering would require
157+
-- maintaining a separate mapping between pool indices and node positions, which
158+
-- much of the existing code does not expect.
159+
--
160+
-- 2. Silently reordering (e.g. turning @relay,spo@ into @spo,relay@) would
161+
-- violate the user's expectations about which node gets which configuration.
162+
--
163+
-- 3. Requiring SPOs first lets us represent the result directly as a 'NonEmpty'
164+
-- list of SPO nodes (guaranteeing at least one) plus a plain list of relays,
165+
-- so the type itself makes an invalid configuration unrepresentable.
166+
parseNodeSpecs :: String -> Either Parsec.ParseError TestnetNodesWithOptions
167+
parseNodeSpecs = parse (nodeSpecsParser <* eof) "Error parsing node specifications"
168+
where
169+
nodeSpecsParser :: Parsec.Parser TestnetNodesWithOptions
170+
nodeSpecsParser = do
171+
specs <- nodeSpec `sepBy1` char ','
172+
let (spos, relays) = span (\(role, _) -> role == Spo) specs
173+
unless (all (\(role, _) -> role == Relay) relays) $
174+
fail "SPO nodes must come before relay nodes. Example: --nodes spo,spo,relay,relay"
175+
case map snd spos of
176+
[] -> fail "Need at least one SPO node to produce blocks."
177+
(s:ss) -> pure $ TestnetNodesWithOptions
178+
{ optSpoNodes = s :| ss
179+
, optRelayNodes = map snd relays
180+
}
181+
182+
nodeSpec :: Parsec.Parser (NodeRole, NodeWithOptions)
183+
nodeSpec = do
184+
role <- nodeRole
185+
bin <- optional $ char ':' *> nodeBinKV
186+
pure (role, NodeWithOptions bin [])
187+
188+
nodeRole :: Parsec.Parser NodeRole
189+
nodeRole =
190+
Spo <$ try (string "spo" <* notFollowedBy (noneOf ",:\"\\"))
191+
<|> Relay <$ try (string "relay" <* notFollowedBy (noneOf ",:\"\\"))
192+
<?> "node role (\"spo\" or \"relay\")"
193+
194+
nodeBinKV :: Parsec.Parser FilePath
195+
nodeBinKV = string "node-bin=" *> (quotedPath <|> unquotedPath) <?> "\"node-bin=<path>\", where <path> is the path to the node binary, optionally quoted if it contains special characters"
196+
197+
quotedPath :: Parsec.Parser FilePath
198+
quotedPath = char '"' *> Parsec.many quotedChar <* char '"'
199+
where
200+
quotedChar = try (char '\\' *> (char '"' <|> char '\\')) <|> noneOf "\""
201+
202+
unquotedPath :: Parsec.Parser FilePath
203+
unquotedPath = many1 (noneOf ",:\"\\")
204+
205+
data NodeRole = Spo | Relay deriving Eq
206+
130207
pOnChainParams :: Parser TestnetOnChainParams
131208
pOnChainParams = fmap (fromMaybe DefaultParams) <$> optional $
132209
pCustomParamsFile <|> pMainnetParams

cardano-testnet/src/Parsers/Run.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ runCardanoOptions = \case
9191
let dirName = envPath fromEnvOptions
9292
unlessM (doesDirectoryExist dirName) $ error $ "The provided path does not exist or is not a directory: " <> dirName
9393
conf <- mkConfigAbs dirName
94-
nodes <- readNodeOptionsFromEnv (unTmpAbsPath (tempAbsPath conf))
94+
nodes <- readNodesWithOptionsFromEnv (unTmpAbsPath (tempAbsPath conf))
9595
runSimpleApp . runResourceT $ do
9696
logInfo $ "Starting testnet in environment: " <> display (tempAbsPath conf)
9797
void $ cardanoTestnet nodes fromEnvRuntimeOptions

cardano-testnet/src/Testnet/Process/RunIO.hs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module Testnet.Process.RunIO
1212
, procNode
1313
, procKesAgent
1414
, execKesAgentControl_
15+
, procCustom
1516
, procFlex
1617
, liftIOAnnotated
1718
) where
@@ -144,8 +145,7 @@ execFlexAny' execConfig pkgBin envBin arguments = GHC.withFrozenCallStack $ do
144145
cp <- procFlex' execConfig pkgBin envBin arguments
145146
liftIOAnnotated $ IO.readCreateProcessWithExitCode cp ""
146147

147-
148-
148+
-- | Like 'procFlex', but takes an explicit 'ExecConfig' instead of using 'defaultExecConfig'.
149149
procFlex'
150150
:: HasCallStack
151151
=> MonadIO m
@@ -160,7 +160,20 @@ procFlex'
160160
-- ^ Captured stdout
161161
procFlex' execConfig pkg binaryEnv arguments = GHC.withFrozenCallStack $ do
162162
bin <- binFlex pkg binaryEnv
163-
return (IO.proc bin arguments)
163+
procCustom' execConfig bin arguments
164+
165+
-- | Build a 'CreateProcess' from an already-resolved binary path, arguments, and 'ExecConfig'.
166+
procCustom'
167+
:: (HasCallStack)
168+
=> MonadIO m
169+
=> ExecConfig
170+
-> FilePath
171+
-- ^ Path to the binary
172+
-> [String]
173+
-- ^ Arguments to the CLI command
174+
-> m CreateProcess
175+
procCustom' execConfig bin arguments = GHC.withFrozenCallStack $
176+
pure (IO.proc bin arguments)
164177
{ IO.env = getLast $ execConfigEnv execConfig
165178
, IO.cwd = getLast $ execConfigCwd execConfig
166179
-- this allows sending signals to the created processes, without killing the test-suite process
@@ -311,6 +324,17 @@ procFlex
311324
-- ^ Captured stdout
312325
procFlex = procFlex' defaultExecConfig
313326

327+
-- | Like 'procFlex', but takes an explicit binary path instead of resolving
328+
-- via package name and environment variable.
329+
procCustom
330+
:: (HasCallStack)
331+
=> FilePath
332+
-- ^ Path to the binary
333+
-> [String]
334+
-- ^ Arguments to the CLI command
335+
-> RIO env CreateProcess
336+
procCustom = procCustom' defaultExecConfig
337+
314338
-- This will also catch async exceptions as well.
315339
liftIOAnnotated :: (HasCallStack, MonadIO m) => IO a -> m a
316340
liftIOAnnotated action = GHC.withFrozenCallStack $

cardano-testnet/src/Testnet/Runtime.hs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import Cardano.Node.Testnet.Paths (defaultSocketName)
5757
import qualified Testnet.Ping as Ping
5858
import Testnet.Process.Run (ProcessError (..), initiateProcess)
5959
import Testnet.Process.RunIO (execCli_, execKesAgentControl_, liftIOAnnotated,
60-
procKesAgent, procNode)
60+
procCustom, procKesAgent, procNode)
6161
import Testnet.Types (TestnetKesAgent (..), TestnetNode (..),
6262
TestnetRuntime (configurationFile), showIpv4Address, testnetSprockets)
6363

@@ -121,11 +121,13 @@ startNode
121121
-- ^ Node port
122122
-> Int
123123
-- ^ Testnet magic
124+
-> Maybe FilePath
125+
-- ^ Optional custom node binary. 'Nothing' uses the default resolution.
124126
-> [String]
125127
-- ^ The command to execute to start the node.
126128
-- @--socket-path@, @--port@, and @--host-addr@ gets added automatically.
127129
-> ExceptT NodeStartFailure m TestnetNode
128-
startNode tp node ipv4 port _testnetMagic nodeCmd = GHC.withFrozenCallStack $ do
130+
startNode tp node ipv4 port _testnetMagic mNodeBin nodeCmd = GHC.withFrozenCallStack $ do
129131
let tempBaseAbsPath = makeTmpBaseAbsPath tp
130132
socketDir = makeSocketDir tp
131133
logDir = makeLogDir tp
@@ -156,7 +158,10 @@ startNode tp node ipv4 port _testnetMagic nodeCmd = GHC.withFrozenCallStack $ do
156158
, "--port", show port
157159
, "--host-addr", showIpv4Address ipv4
158160
]
159-
nodeProcess <- newExceptT . fmap (first ExecutableRelatedFailure) . try $ runRIO () $ procNode completeNodeCmd
161+
nodeProcess <- newExceptT . fmap (first ExecutableRelatedFailure) . try $ runRIO () $
162+
case mNodeBin of
163+
Nothing -> procNode completeNodeCmd
164+
Just bin -> procCustom bin completeNodeCmd
160165

161166
-- The port number if it is obtained using 'H.randomPort', it is firstly bound to and then closed. The closing
162167
-- and release in the operating system is done asynchronously and can be slow. Here we wait until the port

0 commit comments

Comments
 (0)