// Copyright 2017 The go-ethereum Authors // This file is part of go-ethereum. // // go-ethereum is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // go-ethereum is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with go-ethereum. If not, see . package main import ( "bytes" "encoding/json" "fmt" "math/rand" "path/filepath" "strconv" "strings" "text/template" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" ) // nodeDockerfile is the Dockerfile required to run an Ethereum node. var nodeDockerfile = ` FROM ethereum/client-go:latest ADD genesis.json /genesis.json {{if .Unlock}} ADD signer.json /signer.json ADD signer.pass /signer.pass {{end}} RUN \ echo 'geth --cache 512 init /genesis.json' > geth.sh && \{{if .Unlock}} echo 'mkdir -p /root/.ethereum/keystore/ && cp /signer.json /root/.ethereum/keystore/' >> geth.sh && \{{end}} echo $'exec geth --networkid {{.NetworkID}} --cache 512 --port {{.Port}} --maxpeers {{.Peers}} {{.LightFlag}} --ethstats \'{{.Ethstats}}\' {{if .Bootnodes}}--bootnodes {{.Bootnodes}}{{end}} {{if .Etherbase}}--miner.etherbase {{.Etherbase}} --mine --miner.threads 1{{end}} {{if .Unlock}}--unlock 0 --password /signer.pass --mine{{end}} --miner.gastarget {{.GasTarget}} --miner.gaslimit {{.GasLimit}} --miner.gasprice {{.GasPrice}}' >> geth.sh ENTRYPOINT ["/bin/sh", "geth.sh"] ` // nodeComposefile is the docker-compose.yml file required to deploy and maintain // an Ethereum node (bootnode or miner for now). var nodeComposefile = ` version: '2' services: {{.Type}}: build: . image: {{.Network}}/{{.Type}} ports: - "{{.Port}}:{{.Port}}" - "{{.Port}}:{{.Port}}/udp" volumes: - {{.Datadir}}:/root/.ethereum{{if .Ethashdir}} - {{.Ethashdir}}:/root/.ethash{{end}} environment: - PORT={{.Port}}/tcp - TOTAL_PEERS={{.TotalPeers}} - LIGHT_PEERS={{.LightPeers}} - STATS_NAME={{.Ethstats}} - MINER_NAME={{.Etherbase}} - GAS_TARGET={{.GasTarget}} - GAS_LIMIT={{.GasLimit}} - GAS_PRICE={{.GasPrice}} logging: driver: "json-file" options: max-size: "1m" max-file: "10" restart: always ` // deployNode deploys a new Ethereum node container to a remote machine via SSH, // docker and docker-compose. If an instance with the specified network name // already exists there, it will be overwritten! func deployNode(client *sshClient, network string, bootnodes []string, config *nodeInfos, nocache bool) ([]byte, error) { kind := "sealnode" if config.keyJSON == "" && config.etherbase == "" { kind = "bootnode" bootnodes = make([]string, 0) } // Generate the content to upload to the server workdir := fmt.Sprintf("%d", rand.Int63()) files := make(map[string][]byte) lightFlag := "" if config.peersLight > 0 { lightFlag = fmt.Sprintf("--lightpeers=%d --lightserv=50", config.peersLight) } dockerfile := new(bytes.Buffer) template.Must(template.New("").Parse(nodeDockerfile)).Execute(dockerfile, map[string]interface{}{ "NetworkID": config.network, "Port": config.port, "Peers": config.peersTotal, "LightFlag": lightFlag, "Bootnodes": strings.Join(bootnodes, ","), "Ethstats": config.ethstats, "Etherbase": config.etherbase, "GasTarget": uint64(1000000 * config.gasTarget), "GasLimit": uint64(1000000 * config.gasLimit), "GasPrice": uint64(1000000000 * config.gasPrice), "Unlock": config.keyJSON != "", }) files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes() composefile := new(bytes.Buffer) template.Must(template.New("").Parse(nodeComposefile)).Execute(composefile, map[string]interface{}{ "Type": kind, "Datadir": config.datadir, "Ethashdir": config.ethashdir, "Network": network, "Port": config.port, "TotalPeers": config.peersTotal, "Light": config.peersLight > 0, "LightPeers": config.peersLight, "Ethstats": config.ethstats[:strings.Index(config.ethstats, ":")], "Etherbase": config.etherbase, "GasTarget": config.gasTarget, "GasLimit": config.gasLimit, "GasPrice": config.gasPrice, }) files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes() files[filepath.Join(workdir, "genesis.json")] = config.genesis if config.keyJSON != "" { files[filepath.Join(workdir, "signer.json")] = []byte(config.keyJSON) files[filepath.Join(workdir, "signer.pass")] = []byte(config.keyPass) } // Upload the deployment files to the remote server (and clean up afterwards) if out, err := client.Upload(files); err != nil { return out, err } defer client.Run("rm -rf " + workdir) // Build and deploy the boot or seal node service if nocache { return nil, client.Stream(fmt.Sprintf("cd %s && docker-compose -p %s build --pull --no-cache && docker-compose -p %s up -d --force-recreate --timeout 60", workdir, network, network)) } return nil, client.Stream(fmt.Sprintf("cd %s && docker-compose -p %s up -d --build --force-recreate --timeout 60", workdir, network)) } // nodeInfos is returned from a boot or seal node status check to allow reporting // various configuration parameters. type nodeInfos struct { genesis []byte network int64 datadir string ethashdir string ethstats string port int enode string peersTotal int peersLight int etherbase string keyJSON string keyPass string gasTarget float64 gasLimit float64 gasPrice float64 } // Report converts the typed struct into a plain string->string map, containing // most - but not all - fields for reporting to the user. func (info *nodeInfos) Report() map[string]string { report := map[string]string{ "Data directory": info.datadir, "Listener port": strconv.Itoa(info.port), "Peer count (all total)": strconv.Itoa(info.peersTotal), "Peer count (light nodes)": strconv.Itoa(info.peersLight), "Ethstats username": info.ethstats, } if info.gasTarget > 0 { // Miner or signer node report["Gas price (minimum accepted)"] = fmt.Sprintf("%0.3f GWei", info.gasPrice) report["Gas floor (baseline target)"] = fmt.Sprintf("%0.3f MGas", info.gasTarget) report["Gas ceil (target maximum)"] = fmt.Sprintf("%0.3f MGas", info.gasLimit) if info.etherbase != "" { // Ethash proof-of-work miner report["Ethash directory"] = info.ethashdir report["Miner account"] = info.etherbase } if info.keyJSON != "" { // Clique proof-of-authority signer var key struct { Address string `json:"address"` } if err := json.Unmarshal([]byte(info.keyJSON), &key); err == nil { report["Signer account"] = common.HexToAddress(key.Address).Hex() } else { log.Error("Failed to retrieve signer address", "err", err) } } } return report } // checkNode does a health-check against a boot or seal node server to verify // whether it's running, and if yes, whether it's responsive. func checkNode(client *sshClient, network string, boot bool) (*nodeInfos, error) { kind := "bootnode" if !boot { kind = "sealnode" } // Inspect a possible bootnode container on the host infos, err := inspectContainer(client, fmt.Sprintf("%s_%s_1", network, kind)) if err != nil { return nil, err } if !infos.running { return nil, ErrServiceOffline } // Resolve a few types from the environmental variables totalPeers, _ := strconv.Atoi(infos.envvars["TOTAL_PEERS"]) lightPeers, _ := strconv.Atoi(infos.envvars["LIGHT_PEERS"]) gasTarget, _ := strconv.ParseFloat(infos.envvars["GAS_TARGET"], 64) gasLimit, _ := strconv.ParseFloat(infos.envvars["GAS_LIMIT"], 64) gasPrice, _ := strconv.ParseFloat(infos.envvars["GAS_PRICE"], 64) // Container available, retrieve its node ID and its genesis json var out []byte if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 geth --exec admin.nodeInfo.id --cache=16 attach", network, kind)); err != nil { return nil, ErrServiceUnreachable } id := bytes.Trim(bytes.TrimSpace(out), "\"") if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 cat /genesis.json", network, kind)); err != nil { return nil, ErrServiceUnreachable } genesis := bytes.TrimSpace(out) keyJSON, keyPass := "", "" if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 cat /signer.json", network, kind)); err == nil { keyJSON = string(bytes.TrimSpace(out)) } if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 cat /signer.pass", network, kind)); err == nil { keyPass = string(bytes.TrimSpace(out)) } // Run a sanity check to see if the devp2p is reachable port := infos.portmap[infos.envvars["PORT"]] if err = checkPort(client.server, port); err != nil { log.Warn(fmt.Sprintf("%s devp2p port seems unreachable", strings.Title(kind)), "server", client.server, "port", port, "err", err) } // Assemble and return the useful infos stats := &nodeInfos{ genesis: genesis, datadir: infos.volumes["/root/.ethereum"], ethashdir: infos.volumes["/root/.ethash"], port: port, peersTotal: totalPeers, peersLight: lightPeers, ethstats: infos.envvars["STATS_NAME"], etherbase: infos.envvars["MINER_NAME"], keyJSON: keyJSON, keyPass: keyPass, gasTarget: gasTarget, gasLimit: gasLimit, gasPrice: gasPrice, } stats.enode = fmt.Sprintf("enode://%s@%s:%d", id, client.address, stats.port) return stats, nil }