Files

477 lines
17 KiB
Go
Raw Permalink Normal View History

// Copyright (C) 2021-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
2022-02-23 16:23:39 -08:00
// Package client is the netrunner control-plane RPC client. Wire is ZAP
// (luxfi/zap) — no gRPC, no grpc-gateway.
2022-02-23 16:23:39 -08:00
package client
import (
"context"
"sync"
"time"
2026-01-14 19:56:11 -08:00
log "github.com/luxfi/log"
2025-07-17 16:56:43 -05:00
"github.com/luxfi/netrunner/local"
"github.com/luxfi/netrunner/rpcpb"
"github.com/luxfi/netrunner/zaprpc"
2022-02-23 16:23:39 -08:00
)
type Config struct {
// Endpoint is "host:port" of the netrunner ZAP server, e.g. "127.0.0.1:8546".
2022-02-23 16:23:39 -08:00
Endpoint string
DialTimeout time.Duration
}
type Client interface {
Ping(ctx context.Context) (*rpcpb.PingResponse, error)
2023-04-03 13:33:21 -03:00
RPCVersion(ctx context.Context) (*rpcpb.RPCVersionResponse, error)
2022-03-21 15:34:03 -03:00
Start(ctx context.Context, execPath string, opts ...OpOption) (*rpcpb.StartResponse, error)
// CreateChains creates chains with VMs and genesis (uses rpcpb.CreateBlockchains)
CreateChains(ctx context.Context, chainSpecs []*rpcpb.BlockchainSpec) (*rpcpb.CreateBlockchainsResponse, error)
// CreateParticipantGroups creates participant groups (uses rpcpb.CreateChains)
CreateParticipantGroups(ctx context.Context, participantsSpecs []*rpcpb.ChainSpec) (*rpcpb.CreateChainsResponse, error)
TransformElasticChains(ctx context.Context, elasticChainSpecs []*rpcpb.ElasticChainSpec) (*rpcpb.TransformElasticChainsResponse, error)
2023-05-22 17:56:39 -04:00
AddPermissionlessValidator(ctx context.Context, validatorSpec []*rpcpb.PermissionlessValidatorSpec) (*rpcpb.AddPermissionlessValidatorResponse, error)
RemoveChainValidator(ctx context.Context, validatorSpec []*rpcpb.RemoveChainValidatorSpec) (*rpcpb.RemoveChainValidatorResponse, error)
2022-02-23 16:23:39 -08:00
Health(ctx context.Context) (*rpcpb.HealthResponse, error)
2023-01-26 10:53:19 -03:00
WaitForHealthy(ctx context.Context) (*rpcpb.WaitForHealthyResponse, error)
2022-02-23 16:23:39 -08:00
URIs(ctx context.Context) ([]string, error)
Status(ctx context.Context) (*rpcpb.StatusResponse, error)
StreamStatus(ctx context.Context, pushInterval time.Duration) (<-chan *rpcpb.ClusterInfo, error)
RemoveNode(ctx context.Context, name string) (*rpcpb.RemoveNodeResponse, error)
2023-02-10 12:16:21 -03:00
PauseNode(ctx context.Context, name string) (*rpcpb.PauseNodeResponse, error)
ResumeNode(ctx context.Context, name string) (*rpcpb.ResumeNodeResponse, error)
RestartNode(ctx context.Context, name string, opts ...OpOption) (*rpcpb.RestartNodeResponse, error)
2022-04-20 10:36:13 -05:00
AddNode(ctx context.Context, name string, execPath string, opts ...OpOption) (*rpcpb.AddNodeResponse, error)
2022-02-23 16:23:39 -08:00
Stop(ctx context.Context) (*rpcpb.StopResponse, error)
2022-04-06 11:19:29 -07:00
AttachPeer(ctx context.Context, nodeName string) (*rpcpb.AttachPeerResponse, error)
SendOutboundMessage(ctx context.Context, nodeName string, peerID string, op uint32, msgBody []byte) (*rpcpb.SendOutboundMessageResponse, error)
2022-02-23 16:23:39 -08:00
Close() error
SaveSnapshot(ctx context.Context, snapshotName string) (*rpcpb.SaveSnapshotResponse, error)
SaveHotSnapshot(ctx context.Context, snapshotName string) (*rpcpb.SaveSnapshotResponse, error)
LoadSnapshot(ctx context.Context, snapshotName string, opts ...OpOption) (*rpcpb.LoadSnapshotResponse, error)
RemoveSnapshot(ctx context.Context, snapshotName string) (*rpcpb.RemoveSnapshotResponse, error)
GetSnapshotNames(ctx context.Context) ([]string, error)
2022-02-23 16:23:39 -08:00
}
type client struct {
cfg Config
logger log.Logger
2022-02-23 16:23:39 -08:00
zc *zaprpc.Client
2022-02-23 16:23:39 -08:00
closed chan struct{}
closeOnce sync.Once
}
func New(cfg Config, logger log.Logger) (Client, error) {
if cfg.DialTimeout == 0 {
cfg.DialTimeout = 10 * time.Second
}
log.Debug("dialing netrunner ZAP server", log.String("endpoint", cfg.Endpoint))
2022-06-03 15:24:07 -03:00
zc, err := zaprpc.NewClient(zaprpc.ClientConfig{
NodeID: "netrunner-client",
ServerAddr: cfg.Endpoint,
DialTimeout: cfg.DialTimeout,
})
2022-02-23 16:23:39 -08:00
if err != nil {
return nil, err
}
return &client{
cfg: cfg,
logger: logger,
zc: zc,
closed: make(chan struct{}),
2022-02-23 16:23:39 -08:00
}, nil
}
func (c *client) Ping(ctx context.Context) (*rpcpb.PingResponse, error) {
c.logger.Info("ping")
return zaprpc.Call[rpcpb.PingRequest, rpcpb.PingResponse](ctx, c.zc, zaprpc.MsgPing, &rpcpb.PingRequest{})
2022-02-23 16:23:39 -08:00
}
2023-04-03 13:33:21 -03:00
func (c *client) RPCVersion(ctx context.Context) (*rpcpb.RPCVersionResponse, error) {
c.logger.Info("rpc version")
return zaprpc.Call[rpcpb.RPCVersionRequest, rpcpb.RPCVersionResponse](ctx, c.zc, zaprpc.MsgRPCVersion, &rpcpb.RPCVersionRequest{})
2023-04-03 13:33:21 -03:00
}
2022-03-21 15:34:03 -03:00
func (c *client) Start(ctx context.Context, execPath string, opts ...OpOption) (*rpcpb.StartResponse, error) {
2022-03-21 15:40:51 -03:00
ret := &Op{numNodes: local.DefaultNumNodes}
2022-02-23 16:23:39 -08:00
ret.applyOpts(opts)
req := &rpcpb.StartRequest{
ExecPath: execPath,
NumNodes: &ret.numNodes,
ChainConfigs: ret.chainConfigs,
UpgradeConfigs: ret.upgradeConfigs,
ChainConfigFiles: ret.chainConfigs,
}
if ret.trackChains != "" {
req.WhitelistedChains = &ret.trackChains
}
if ret.rootDataDir != "" {
req.RootDataDir = &ret.rootDataDir
}
if ret.pluginDir != "" {
2023-01-06 16:48:41 -03:00
req.PluginDir = ret.pluginDir
}
if len(ret.chainSpecs) > 0 {
req.BlockchainSpecs = ret.chainSpecs
}
2022-05-05 13:18:49 -05:00
if ret.globalNodeConfig != "" {
req.GlobalNodeConfig = &ret.globalNodeConfig
}
if ret.customNodeConfigs != nil {
req.CustomNodeConfigs = ret.customNodeConfigs
}
req.ReassignPortsIfUsed = &ret.reassignPortsIfUsed
req.DynamicPorts = &ret.dynamicPorts
c.logger.Info("start")
return zaprpc.Call[rpcpb.StartRequest, rpcpb.StartResponse](ctx, c.zc, zaprpc.MsgStart, req)
2022-02-23 16:23:39 -08:00
}
func (c *client) CreateChains(ctx context.Context, chainSpecs []*rpcpb.BlockchainSpec) (*rpcpb.CreateBlockchainsResponse, error) {
c.logger.Info("create chains")
return zaprpc.Call[rpcpb.CreateBlockchainsRequest, rpcpb.CreateBlockchainsResponse](ctx, c.zc, zaprpc.MsgCreateBlockchains, &rpcpb.CreateBlockchainsRequest{BlockchainSpecs: chainSpecs})
}
func (c *client) CreateParticipantGroups(ctx context.Context, participantsSpecs []*rpcpb.ChainSpec) (*rpcpb.CreateChainsResponse, error) {
c.logger.Info("create participant groups")
return zaprpc.Call[rpcpb.CreateChainsRequest, rpcpb.CreateChainsResponse](ctx, c.zc, zaprpc.MsgCreateChains, &rpcpb.CreateChainsRequest{ChainSpecs: participantsSpecs})
2022-06-04 00:07:16 -03:00
}
func (c *client) TransformElasticChains(ctx context.Context, elasticChainSpecs []*rpcpb.ElasticChainSpec) (*rpcpb.TransformElasticChainsResponse, error) {
c.logger.Info("transform chains")
return zaprpc.Call[rpcpb.TransformElasticChainsRequest, rpcpb.TransformElasticChainsResponse](ctx, c.zc, zaprpc.MsgTransformElasticChains, &rpcpb.TransformElasticChainsRequest{ElasticChainSpec: elasticChainSpecs})
2023-04-13 18:03:53 -04:00
}
2023-05-22 17:56:39 -04:00
func (c *client) AddPermissionlessValidator(ctx context.Context, validatorSpec []*rpcpb.PermissionlessValidatorSpec) (*rpcpb.AddPermissionlessValidatorResponse, error) {
c.logger.Info("add permissionless validators to elastic chains")
return zaprpc.Call[rpcpb.AddPermissionlessValidatorRequest, rpcpb.AddPermissionlessValidatorResponse](ctx, c.zc, zaprpc.MsgAddPermissionlessValidator, &rpcpb.AddPermissionlessValidatorRequest{ValidatorSpec: validatorSpec})
2023-05-22 17:56:39 -04:00
}
func (c *client) RemoveChainValidator(ctx context.Context, validatorSpec []*rpcpb.RemoveChainValidatorSpec) (*rpcpb.RemoveChainValidatorResponse, error) {
c.logger.Info("remove chain validator")
return zaprpc.Call[rpcpb.RemoveChainValidatorRequest, rpcpb.RemoveChainValidatorResponse](ctx, c.zc, zaprpc.MsgRemoveChainValidator, &rpcpb.RemoveChainValidatorRequest{ValidatorSpec: validatorSpec})
2023-05-25 17:19:49 -04:00
}
2022-02-23 16:23:39 -08:00
func (c *client) Health(ctx context.Context) (*rpcpb.HealthResponse, error) {
c.logger.Info("health")
return zaprpc.Call[rpcpb.HealthRequest, rpcpb.HealthResponse](ctx, c.zc, zaprpc.MsgHealth, &rpcpb.HealthRequest{})
2022-02-23 16:23:39 -08:00
}
2023-01-26 10:53:19 -03:00
func (c *client) WaitForHealthy(ctx context.Context) (*rpcpb.WaitForHealthyResponse, error) {
c.logger.Info("wait for healthy")
return zaprpc.Call[rpcpb.WaitForHealthyRequest, rpcpb.WaitForHealthyResponse](ctx, c.zc, zaprpc.MsgWaitForHealthy, &rpcpb.WaitForHealthyRequest{})
2023-01-26 10:53:19 -03:00
}
2022-02-23 16:23:39 -08:00
func (c *client) URIs(ctx context.Context) ([]string, error) {
c.logger.Info("uris")
resp, err := zaprpc.Call[rpcpb.URIsRequest, rpcpb.URIsResponse](ctx, c.zc, zaprpc.MsgURIs, &rpcpb.URIsRequest{})
2022-02-23 16:23:39 -08:00
if err != nil {
return nil, err
}
return resp.Uris, nil
}
func (c *client) Status(ctx context.Context) (*rpcpb.StatusResponse, error) {
c.logger.Info("status")
return zaprpc.Call[rpcpb.StatusRequest, rpcpb.StatusResponse](ctx, c.zc, zaprpc.MsgStatus, &rpcpb.StatusRequest{})
2022-02-23 16:23:39 -08:00
}
// StreamStatus emits one ClusterInfo per pushInterval into the returned
// channel until ctx or the client is closed. ZAP doesn't model
// server-streams natively, so this is a client-driven Status() poll —
// observably identical to the previous gRPC server-streaming RPC,
// strictly less plumbing.
2022-02-23 16:23:39 -08:00
func (c *client) StreamStatus(ctx context.Context, pushInterval time.Duration) (<-chan *rpcpb.ClusterInfo, error) {
if pushInterval <= 0 {
pushInterval = time.Second
2022-02-23 16:23:39 -08:00
}
ch := make(chan *rpcpb.ClusterInfo, 1)
go func() {
defer close(ch)
ticker := time.NewTicker(pushInterval)
defer ticker.Stop()
2022-02-23 16:23:39 -08:00
for {
resp, err := c.Status(ctx)
if err != nil {
c.logger.Debug("status poll error", log.Err(err))
return
}
2022-02-23 16:23:39 -08:00
select {
case ch <- resp.ClusterInfo:
2022-02-23 16:23:39 -08:00
case <-ctx.Done():
return
case <-c.closed:
return
}
select {
case <-ticker.C:
case <-ctx.Done():
return
case <-c.closed:
2022-02-23 16:23:39 -08:00
return
}
}
}()
return ch, nil
}
func (c *client) Stop(ctx context.Context) (*rpcpb.StopResponse, error) {
c.logger.Info("stop")
return zaprpc.Call[rpcpb.StopRequest, rpcpb.StopResponse](ctx, c.zc, zaprpc.MsgStop, &rpcpb.StopRequest{})
2022-02-23 16:23:39 -08:00
}
2022-04-20 10:36:13 -05:00
func (c *client) AddNode(ctx context.Context, name string, execPath string, opts ...OpOption) (*rpcpb.AddNodeResponse, error) {
ret := &Op{}
ret.applyOpts(opts)
2022-04-25 18:09:16 -05:00
req := &rpcpb.AddNodeRequest{
Name: name,
ExecPath: execPath,
NodeConfig: &ret.globalNodeConfig,
ChainConfigs: ret.chainConfigs,
UpgradeConfigs: ret.upgradeConfigs,
ChainConfigFiles: ret.chainConfigs,
2022-04-25 18:09:16 -05:00
}
if ret.pluginDir != "" {
2023-01-06 16:48:41 -03:00
req.PluginDir = ret.pluginDir
}
c.logger.Info("add node", log.String("name", name))
return zaprpc.Call[rpcpb.AddNodeRequest, rpcpb.AddNodeResponse](ctx, c.zc, zaprpc.MsgAddNode, req)
2022-04-20 10:36:13 -05:00
}
2022-02-23 16:23:39 -08:00
func (c *client) RemoveNode(ctx context.Context, name string) (*rpcpb.RemoveNodeResponse, error) {
c.logger.Info("remove node", log.String("name", name))
return zaprpc.Call[rpcpb.RemoveNodeRequest, rpcpb.RemoveNodeResponse](ctx, c.zc, zaprpc.MsgRemoveNode, &rpcpb.RemoveNodeRequest{Name: name})
2022-02-23 16:23:39 -08:00
}
2023-02-10 12:16:21 -03:00
func (c *client) PauseNode(ctx context.Context, name string) (*rpcpb.PauseNodeResponse, error) {
c.logger.Info("pause node", log.String("name", name))
return zaprpc.Call[rpcpb.PauseNodeRequest, rpcpb.PauseNodeResponse](ctx, c.zc, zaprpc.MsgPauseNode, &rpcpb.PauseNodeRequest{Name: name})
2023-02-10 12:16:21 -03:00
}
func (c *client) ResumeNode(ctx context.Context, name string) (*rpcpb.ResumeNodeResponse, error) {
c.logger.Info("resume node", log.String("name", name))
return zaprpc.Call[rpcpb.ResumeNodeRequest, rpcpb.ResumeNodeResponse](ctx, c.zc, zaprpc.MsgResumeNode, &rpcpb.ResumeNodeRequest{Name: name})
2023-02-10 12:16:21 -03:00
}
func (c *client) RestartNode(ctx context.Context, name string, opts ...OpOption) (*rpcpb.RestartNodeResponse, error) {
2022-02-23 16:23:39 -08:00
ret := &Op{}
ret.applyOpts(opts)
req := &rpcpb.RestartNodeRequest{Name: name}
if ret.execPath != "" {
req.ExecPath = &ret.execPath
}
if ret.pluginDir != "" {
2023-01-06 16:48:41 -03:00
req.PluginDir = ret.pluginDir
}
if ret.trackChains != "" {
req.WhitelistedChains = &ret.trackChains
}
2022-07-29 08:42:21 -07:00
req.ChainConfigs = ret.chainConfigs
req.UpgradeConfigs = ret.upgradeConfigs
req.ChainConfigFiles = ret.chainConfigs
c.logger.Info("restart node", log.String("name", name))
return zaprpc.Call[rpcpb.RestartNodeRequest, rpcpb.RestartNodeResponse](ctx, c.zc, zaprpc.MsgRestartNode, req)
2022-02-23 16:23:39 -08:00
}
2022-04-06 11:19:29 -07:00
func (c *client) AttachPeer(ctx context.Context, nodeName string) (*rpcpb.AttachPeerResponse, error) {
c.logger.Info("attaching peer", log.String("name", nodeName))
return zaprpc.Call[rpcpb.AttachPeerRequest, rpcpb.AttachPeerResponse](ctx, c.zc, zaprpc.MsgAttachPeer, &rpcpb.AttachPeerRequest{NodeName: nodeName})
2022-04-06 11:19:29 -07:00
}
func (c *client) SendOutboundMessage(ctx context.Context, nodeName string, peerID string, op uint32, msgBody []byte) (*rpcpb.SendOutboundMessageResponse, error) {
c.logger.Info("sending outbound message", log.String("name", nodeName), log.String("peer-ID", peerID))
return zaprpc.Call[rpcpb.SendOutboundMessageRequest, rpcpb.SendOutboundMessageResponse](ctx, c.zc, zaprpc.MsgSendOutboundMessage, &rpcpb.SendOutboundMessageRequest{
2022-04-06 11:19:29 -07:00
NodeName: nodeName,
PeerId: peerID,
Op: op,
Bytes: msgBody,
})
}
func (c *client) SaveSnapshot(ctx context.Context, snapshotName string) (*rpcpb.SaveSnapshotResponse, error) {
c.logger.Info("save snapshot", log.String("snapshot-name", snapshotName))
return zaprpc.Call[rpcpb.SaveSnapshotRequest, rpcpb.SaveSnapshotResponse](ctx, c.zc, zaprpc.MsgSaveSnapshot, &rpcpb.SaveSnapshotRequest{SnapshotName: snapshotName})
}
func (c *client) SaveHotSnapshot(ctx context.Context, snapshotName string) (*rpcpb.SaveSnapshotResponse, error) {
c.logger.Info("save hot snapshot", log.String("snapshot-name", snapshotName))
return zaprpc.Call[rpcpb.SaveSnapshotRequest, rpcpb.SaveSnapshotResponse](ctx, c.zc, zaprpc.MsgSaveHotSnapshot, &rpcpb.SaveSnapshotRequest{SnapshotName: snapshotName})
}
func (c *client) LoadSnapshot(ctx context.Context, snapshotName string, opts ...OpOption) (*rpcpb.LoadSnapshotResponse, error) {
c.logger.Info("load snapshot", log.String("snapshot-name", snapshotName))
ret := &Op{}
ret.applyOpts(opts)
req := rpcpb.LoadSnapshotRequest{
SnapshotName: snapshotName,
ChainConfigs: ret.chainConfigs,
UpgradeConfigs: ret.upgradeConfigs,
ChainConfigFiles: ret.chainConfigs,
}
if ret.execPath != "" {
req.ExecPath = &ret.execPath
}
if ret.pluginDir != "" {
2023-01-06 16:48:41 -03:00
req.PluginDir = ret.pluginDir
}
2022-06-14 15:22:40 -04:00
if ret.rootDataDir != "" {
req.RootDataDir = &ret.rootDataDir
}
if ret.globalNodeConfig != "" {
req.GlobalNodeConfig = &ret.globalNodeConfig
}
req.ReassignPortsIfUsed = &ret.reassignPortsIfUsed
return zaprpc.Call[rpcpb.LoadSnapshotRequest, rpcpb.LoadSnapshotResponse](ctx, c.zc, zaprpc.MsgLoadSnapshot, &req)
}
func (c *client) RemoveSnapshot(ctx context.Context, snapshotName string) (*rpcpb.RemoveSnapshotResponse, error) {
c.logger.Info("remove snapshot", log.String("snapshot-name", snapshotName))
return zaprpc.Call[rpcpb.RemoveSnapshotRequest, rpcpb.RemoveSnapshotResponse](ctx, c.zc, zaprpc.MsgRemoveSnapshot, &rpcpb.RemoveSnapshotRequest{SnapshotName: snapshotName})
}
func (c *client) GetSnapshotNames(ctx context.Context) ([]string, error) {
c.logger.Info("get snapshot names")
resp, err := zaprpc.Call[rpcpb.GetSnapshotNamesRequest, rpcpb.GetSnapshotNamesResponse](ctx, c.zc, zaprpc.MsgGetSnapshotNames, &rpcpb.GetSnapshotNamesRequest{})
if err != nil {
return nil, err
}
return resp.SnapshotNames, nil
}
2022-02-23 16:23:39 -08:00
func (c *client) Close() error {
c.closeOnce.Do(func() {
close(c.closed)
})
return c.zc.Close()
2022-02-23 16:23:39 -08:00
}
type Op struct {
numNodes uint32
execPath string
trackChains string
globalNodeConfig string
rootDataDir string
pluginDir string
chainSpecs []*rpcpb.BlockchainSpec
customNodeConfigs map[string]string
numChains uint32
chainConfigs map[string]string
upgradeConfigs map[string]string
pChainConfigs map[string]string
reassignPortsIfUsed bool
dynamicPorts bool
2022-02-23 16:23:39 -08:00
}
type OpOption func(*Op)
func (op *Op) applyOpts(opts []OpOption) {
for _, opt := range opts {
opt(op)
}
}
2022-05-05 13:18:49 -05:00
func WithGlobalNodeConfig(nodeConfig string) OpOption {
return func(op *Op) {
2022-05-05 13:18:49 -05:00
op.globalNodeConfig = nodeConfig
}
}
2022-03-21 15:34:03 -03:00
func WithNumNodes(numNodes uint32) OpOption {
return func(op *Op) {
op.numNodes = numNodes
}
}
func WithExecPath(execPath string) OpOption {
return func(op *Op) {
op.execPath = execPath
}
}
func WithWhitelistedChains(trackChains string) OpOption {
2022-02-23 16:23:39 -08:00
return func(op *Op) {
op.trackChains = trackChains
}
}
func WithTrackChains(trackChains string) OpOption {
return func(op *Op) {
op.trackChains = trackChains
2022-02-23 16:23:39 -08:00
}
}
func WithRootDataDir(rootDataDir string) OpOption {
return func(op *Op) {
op.rootDataDir = rootDataDir
}
}
func WithPluginDir(pluginDir string) OpOption {
return func(op *Op) {
op.pluginDir = pluginDir
}
}
// WithChainSpecs sets the chain specifications for creating chains with VMs
func WithChainSpecs(chainSpecs []*rpcpb.BlockchainSpec) OpOption {
return func(op *Op) {
op.chainSpecs = chainSpecs
}
}
2022-04-20 23:04:28 -05:00
// Map from chain name to its configuration json contents.
func WithChainConfigs(chainConfigs map[string]string) OpOption {
return func(op *Op) {
op.chainConfigs = chainConfigs
2022-07-04 15:34:12 -03:00
}
}
2022-07-29 08:42:21 -07:00
// Map from chain name to its upgrade json contents.
func WithUpgradeConfigs(upgradeConfigs map[string]string) OpOption {
return func(op *Op) {
op.upgradeConfigs = upgradeConfigs
}
}
// Map from chain id to its configuration json contents.
func WithChainConfigFiles(chainConfigs map[string]string) OpOption {
return func(op *Op) {
op.chainConfigs = chainConfigs
}
}
2022-05-05 13:18:49 -05:00
// Map from node name to its custom node config
func WithCustomNodeConfigs(customNodeConfigs map[string]string) OpOption {
return func(op *Op) {
op.customNodeConfigs = customNodeConfigs
}
}
func WithNumChains(numChains uint32) OpOption {
2022-06-14 19:08:01 -03:00
return func(op *Op) {
op.numChains = numChains
2022-04-20 23:04:28 -05:00
}
}
func WithReassignPortsIfUsed(reassignPortsIfUsed bool) OpOption {
return func(op *Op) {
op.reassignPortsIfUsed = reassignPortsIfUsed
}
}
func WithDynamicPorts(dynamicPorts bool) OpOption {
return func(op *Op) {
op.dynamicPorts = dynamicPorts
}
}