diff --git a/cmd/nodecmd/create.go b/cmd/nodecmd/create.go index 5f8d66553..736bc7621 100644 --- a/cmd/nodecmd/create.go +++ b/cmd/nodecmd/create.go @@ -5,6 +5,7 @@ package nodecmd import ( "fmt" "math" + "net/netip" "os" "os/user" "path/filepath" @@ -76,6 +77,12 @@ var ( grafanaPkg string wizSubnet string publicHTTPPortAccess bool + + bootstrapIDs []string + bootstrapIPs []string + genesisPath string + upgradePath string + useEtnaDevnet bool ) func newCreateCmd() *cobra.Command { @@ -128,6 +135,11 @@ will apply to all nodes in the cluster`, cmd.Flags().IntVar(&volumeSize, "aws-volume-size", constants.CloudServerStorageSize, "AWS volume size in GB") cmd.Flags().BoolVar(&replaceKeyPair, "auto-replace-keypair", false, "automatically replaces key pair to access node if previous key pair is not found") cmd.Flags().BoolVar(&publicHTTPPortAccess, "public-http-port", false, "allow public access to avalanchego HTTP port") + cmd.Flags().StringArrayVar(&bootstrapIDs, "bootstrap-id", []string{}, "nodeIDs of bootstrap nodes") + cmd.Flags().StringArrayVar(&bootstrapIPs, "bootstrap-ip", []string{}, "IP:port pairs of bootstrap nodes") + cmd.Flags().StringVar(&genesisPath, "genesis", "", "path to genesis file") + cmd.Flags().StringVar(&upgradePath, "upgrade", "", "path to upgrade file") + cmd.Flags().BoolVar(&useEtnaDevnet, "etna-devnet", false, "use Etna devnet. Prepopulated with Etna DevNet bootstrap configuration along with genesis and upgrade files") return cmd } @@ -199,6 +211,25 @@ func preCreateChecks(clusterName string) error { if err := failForExternal(clusterName); err != nil { return err } + // bootsrap checks + if useEtnaDevnet && (len(bootstrapIDs) != 0 || len(bootstrapIPs) != 0 || genesisPath != "" || upgradePath != "") { + return fmt.Errorf("etna devnet uses predefined bootsrap configuration") + } + if len((bootstrapIDs)) != len(bootstrapIPs) { + return fmt.Errorf("number of bootstrap ids and ip:port pairs must be equal") + } + if genesisPath != "" && !utils.FileExists(genesisPath) { + return fmt.Errorf("genesis file %s does not exist", genesisPath) + } + if upgradePath != "" && !utils.FileExists(upgradePath) { + return fmt.Errorf("upgrade file %s does not exist", upgradePath) + } + // check ip:port pairs + for _, ip := range bootstrapIPs { + if _, err := netip.ParseAddrPort(ip); err != nil { + return fmt.Errorf("invalid ip:port pair %s", ip) + } + } return nil } @@ -240,21 +271,45 @@ func stringToAWSVolumeType(input string) types.VolumeType { } func createNodes(cmd *cobra.Command, args []string) error { + var err error clusterName := args[0] + network := models.UndefinedNetwork if err := preCreateChecks(clusterName); err != nil { return err } - network, err := networkoptions.GetNetworkFromCmdLineFlags( - app, - "", - globalNetworkFlags, - false, - true, - createSupportedNetworkOptions, - "", - ) - if err != nil { - return err + // etna devnet constants + if useEtnaDevnet { + network = models.NewDevnetNetwork(constants.EtnaDevnetEndpoint, constants.EtnaDevnetNetworkID) + bootstrapIDs = constants.EtnaDevnetBootstrapNodeIDs + bootstrapIPs = constants.EtnaDevnetBootstrapIPs + genesisTmpFile, err := os.CreateTemp("", "genesis") + if err != nil { + return err + } + genesisPath = genesisTmpFile.Name() + upgradeTmpFile, err := os.CreateTemp("", "upgrade") + if err != nil { + return err + } + upgradePath = upgradeTmpFile.Name() + + defer func() { + _ = os.Remove(genesisTmpFile.Name()) + _ = os.Remove(upgradeTmpFile.Name()) + }() + } else { + network, err = networkoptions.GetNetworkFromCmdLineFlags( + app, + "", + globalNetworkFlags, + false, + true, + createSupportedNetworkOptions, + "", + ) + if err != nil { + return err + } } network = models.NewNetworkFromCluster(network, clusterName) globalNetworkFlags.UseDevnet = network.Kind == models.Devnet // set globalNetworkFlags.UseDevnet to true if network is devnet for further use @@ -715,7 +770,15 @@ func createNodes(cmd *cobra.Command, args []string) error { spinner = spinSession.SpinToUser(utils.ScriptLog(host.NodeID, "Setup AvalancheGo")) // check if host is a API host publicAccessToHTTPPort := slices.Contains(cloudConfigMap.GetAllAPIInstanceIDs(), host.GetCloudID()) || publicHTTPPortAccess - if err := docker.ComposeSSHSetupNode(host, network, avalancheGoVersion, addMonitoring, publicAccessToHTTPPort); err != nil { + if err := docker.ComposeSSHSetupNode(host, + network, + avalancheGoVersion, + bootstrapIDs, + bootstrapIPs, + genesisPath, + upgradePath, + addMonitoring, + publicAccessToHTTPPort); err != nil { nodeResults.AddResult(host.NodeID, nil, err) ux.SpinFailWithError(spinner, "", err) return @@ -726,7 +789,7 @@ func createNodes(cmd *cobra.Command, args []string) error { wg.Wait() ux.Logger.Info("Create and setup nodes time took: %s", time.Since(startTime)) spinSession.Stop() - if network.Kind == models.Devnet { + if network.Kind == models.Devnet && !useEtnaDevnet { if err := setupDevnet(clusterName, hosts, apiNodeIPMap); err != nil { return err } diff --git a/pkg/docker/compose.go b/pkg/docker/compose.go index 9136e9e53..e964508d9 100644 --- a/pkg/docker/compose.go +++ b/pkg/docker/compose.go @@ -19,7 +19,7 @@ import ( "github.com/ava-labs/avalanche-cli/pkg/ux" ) -type dockerComposeInputs struct { +type DockerComposeInputs struct { WithMonitoring bool WithAvalanchego bool AvalanchegoVersion string @@ -31,7 +31,7 @@ type dockerComposeInputs struct { //go:embed templates/*.docker-compose.yml var composeTemplate embed.FS -func renderComposeFile(composePath string, composeDesc string, templateVars dockerComposeInputs) ([]byte, error) { +func renderComposeFile(composePath string, composeDesc string, templateVars DockerComposeInputs) ([]byte, error) { compose, err := composeTemplate.ReadFile(composePath) if err != nil { return nil, err @@ -206,7 +206,7 @@ func ComposeOverSSH( host *models.Host, timeout time.Duration, composePath string, - composeVars dockerComposeInputs, + composeVars DockerComposeInputs, ) error { remoteComposeFile := utils.GetRemoteComposeFile() startTime := time.Now() diff --git a/pkg/docker/config.go b/pkg/docker/config.go index 0a9d501f0..44d0e6065 100644 --- a/pkg/docker/config.go +++ b/pkg/docker/config.go @@ -5,6 +5,8 @@ package docker import ( "os" + "path/filepath" + "strings" "github.com/ava-labs/avalanche-cli/pkg/constants" "github.com/ava-labs/avalanche-cli/pkg/models" @@ -12,11 +14,27 @@ import ( "github.com/ava-labs/avalanche-cli/pkg/utils" ) -func prepareAvalanchegoConfig(host *models.Host, network models.Network, publicAccess bool) (string, string, error) { +func prepareAvalanchegoConfig( + host *models.Host, + network models.Network, + avalanchegoBootstrapIDs []string, + avalanchegoBootstrapIPs []string, + avalanchegoGenesisFilePath string, + avalanchegoUpgradeFilePath string, + publicAccess bool, +) (string, string, error) { avagoConf := remoteconfig.PrepareAvalancheConfig(host.IP, network.NetworkIDFlagValue(), nil) if publicAccess || utils.IsE2E() { avagoConf.HTTPHost = "0.0.0.0" } + avagoConf.BootstrapIPs = strings.Join(avalanchegoBootstrapIPs, ",") + avagoConf.BootstrapIDs = strings.Join(avalanchegoBootstrapIDs, ",") + if avalanchegoGenesisFilePath != "" { + avagoConf.GenesisPath = filepath.Join(constants.DockerNodeConfigPath, constants.GenesisFileName) + } + if avalanchegoUpgradeFilePath != "" { + avagoConf.UpgradePath = filepath.Join(constants.DockerNodeConfigPath, constants.UpgradeFileName) + } nodeConf, err := remoteconfig.RenderAvalancheNodeConfig(avagoConf) if err != nil { return "", "", err diff --git a/pkg/docker/ssh.go b/pkg/docker/ssh.go index ef012b226..05377c809 100644 --- a/pkg/docker/ssh.go +++ b/pkg/docker/ssh.go @@ -25,7 +25,17 @@ func ValidateComposeFile(host *models.Host, composeFile string, timeout time.Dur } // ComposeSSHSetupNode sets up an AvalancheGo node and dependencies on a remote host over SSH. -func ComposeSSHSetupNode(host *models.Host, network models.Network, avalancheGoVersion string, withMonitoring bool, publicAccessToHTTPPort bool) error { +func ComposeSSHSetupNode( + host *models.Host, + network models.Network, + avalancheGoVersion string, + avalanchegoBootstrapIDs []string, + avalanchegoBootstrapIPs []string, + avalanchegoGenesisFilePath string, + avalanchegoUpgradeFilePath string, + withMonitoring bool, + publicAccessToHTTPPort bool, +) error { startTime := time.Now() folderStructure := remoteconfig.RemoteFoldersToCreateAvalanchego() for _, dir := range folderStructure { @@ -41,7 +51,15 @@ func ComposeSSHSetupNode(host *models.Host, network models.Network, avalancheGoV return err } ux.Logger.Info("AvalancheGo Docker image %s ready on %s[%s] after %s", avagoDockerImage, host.NodeID, host.IP, time.Since(startTime)) - nodeConfFile, cChainConfFile, err := prepareAvalanchegoConfig(host, network, publicAccessToHTTPPort) + nodeConfFile, cChainConfFile, err := prepareAvalanchegoConfig( + host, + network, + avalanchegoBootstrapIDs, + avalanchegoBootstrapIPs, + avalanchegoGenesisFilePath, + avalanchegoUpgradeFilePath, + publicAccessToHTTPPort, + ) if err != nil { return err } @@ -60,12 +78,22 @@ func ComposeSSHSetupNode(host *models.Host, network models.Network, avalancheGoV if err := host.Upload(cChainConfFile, remoteconfig.GetRemoteAvalancheCChainConfig(), constants.SSHFileOpsTimeout); err != nil { return err } + if avalanchegoGenesisFilePath != "" { + if err := host.Upload(avalanchegoGenesisFilePath, remoteconfig.GetRemoteAvalancheGenesis(), constants.SSHFileOpsTimeout); err != nil { + return err + } + } + if avalanchegoUpgradeFilePath != "" { + if err := host.Upload(avalanchegoUpgradeFilePath, remoteconfig.GetRemoteAvalancheUpgrade(), constants.SSHFileOpsTimeout); err != nil { + return err + } + } ux.Logger.Info("AvalancheGo configs uploaded to %s[%s] after %s", host.NodeID, host.IP, time.Since(startTime)) return ComposeOverSSH("Compose Node", host, constants.SSHScriptTimeout, "templates/avalanchego.docker-compose.yml", - dockerComposeInputs{ + DockerComposeInputs{ AvalanchegoVersion: avalancheGoVersion, WithMonitoring: withMonitoring, WithAvalanchego: true, @@ -80,7 +108,7 @@ func ComposeSSHSetupLoadTest(host *models.Host) error { host, constants.SSHScriptTimeout, "templates/avalanchego.docker-compose.yml", - dockerComposeInputs{ + DockerComposeInputs{ WithMonitoring: true, WithAvalanchego: false, }) @@ -133,7 +161,7 @@ func ComposeSSHSetupMonitoring(host *models.Host) error { host, constants.SSHScriptTimeout, "templates/monitoring.docker-compose.yml", - dockerComposeInputs{}) + DockerComposeInputs{}) } func ComposeSSHSetupAWMRelayer(host *models.Host) error { @@ -141,5 +169,5 @@ func ComposeSSHSetupAWMRelayer(host *models.Host) error { host, constants.SSHScriptTimeout, "templates/awmrelayer.docker-compose.yml", - dockerComposeInputs{}) + DockerComposeInputs{}) } diff --git a/pkg/remoteconfig/avalanche.go b/pkg/remoteconfig/avalanche.go index 181bc51f0..a51c140f9 100644 --- a/pkg/remoteconfig/avalanche.go +++ b/pkg/remoteconfig/avalanche.go @@ -105,6 +105,10 @@ func GetRemoteAvalancheGenesis() string { return filepath.Join(constants.CloudNodeConfigPath, constants.GenesisFileName) } +func GetRemoteAvalancheUpgrade() string { + return filepath.Join(constants.CloudNodeConfigPath, constants.UpgradeFileName) +} + func GetRemoteAvalancheAliasesConfig() string { return filepath.Join(constants.CloudNodeConfigPath, "chains", constants.AliasesFileName) } diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index 325bc7ccd..79ddddaf8 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -182,8 +182,18 @@ func RunSSHUpgradeAvalanchego(host *models.Host, network models.Network, avalanc if err != nil { return err } - - if err := docker.ComposeSSHSetupNode(host, network, avalancheGoVersion, withMonitoring, publicAccessToHTTPPort); err != nil { + if err := docker.ComposeOverSSH("Compose Node", + host, + constants.SSHScriptTimeout, + "templates/avalanchego.docker-compose.yml", + docker.DockerComposeInputs{ + AvalanchegoVersion: avalancheGoVersion, + WithMonitoring: withMonitoring, + WithAvalanchego: true, + E2E: utils.IsE2E(), + E2EIP: utils.E2EConvertIP(host.IP), + E2ESuffix: utils.E2ESuffix(host.IP), + }); err != nil { return err } return docker.RestartDockerCompose(host, constants.SSHLongRunningScriptTimeout) @@ -424,21 +434,21 @@ func RunSSHSetupDevNet(host *models.Host, nodeInstanceDirPath string) error { } if err := host.Upload( filepath.Join(nodeInstanceDirPath, constants.GenesisFileName), - filepath.Join(constants.CloudNodeConfigPath, constants.GenesisFileName), + remoteconfig.GetRemoteAvalancheGenesis(), constants.SSHFileOpsTimeout, ); err != nil { return err } if err := host.Upload( filepath.Join(nodeInstanceDirPath, constants.UpgradeFileName), - filepath.Join(constants.CloudNodeConfigPath, constants.UpgradeFileName), + remoteconfig.GetRemoteAvalancheUpgrade(), constants.SSHFileOpsTimeout, ); err != nil { return err } if err := host.Upload( filepath.Join(nodeInstanceDirPath, constants.NodeFileName), - filepath.Join(constants.CloudNodeConfigPath, constants.NodeFileName), + remoteconfig.GetRemoteAvalancheNodeConfig(), constants.SSHFileOpsTimeout, ); err != nil { return err