From f18b14d395cf9e590c8db54b40bc0957ece69b91 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sat, 7 Dec 2019 12:33:01 +0000 Subject: [PATCH] new TestDaemon API for controlling test-suite & regtest daemons --- mmgen/exception.py | 1 + mmgen/test_daemon.py | 294 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 1 + 3 files changed, 296 insertions(+) create mode 100755 mmgen/test_daemon.py diff --git a/mmgen/exception.py b/mmgen/exception.py index 9f397a3c..e24e8424 100755 --- a/mmgen/exception.py +++ b/mmgen/exception.py @@ -49,6 +49,7 @@ class WalletFileError(Exception): mmcode = 3 class HexadecimalStringError(Exception): mmcode = 3 class SeedLengthError(Exception): mmcode = 3 class PrivateKeyError(Exception): mmcode = 3 +class MMGenCalledProcessError(Exception): mmcode = 3 # 4: red hl, 'MMGen Fatal Error' + exception + message class BadMMGenTxID(Exception): mmcode = 4 diff --git a/mmgen/test_daemon.py b/mmgen/test_daemon.py new file mode 100755 index 00000000..128ab7ec --- /dev/null +++ b/mmgen/test_daemon.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution +# Copyright (C)2013-2019 The MMGen Project +# +# This program 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. +# +# This program 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 this program. If not, see . + +""" +test_daemon.py: Daemon control classes for MMGen test suite and regtest mode +""" + +from subprocess import run,PIPE +from mmgen.exception import * +from mmgen.common import * + +class TestDaemon(MMGenObject): + cfg_file_hdr = '' + + subclasses_must_implement = ('state','do_stop','stop_cmd') + + network_ids = ('btc','btc_tn','bch','bch_tn','ltc','ltc_tn','xmr') + debug = False + wait = True + use_pidfile = True + + testnet_arg = [] + + coind_args = [] + cli_args = [] + shared_args = [] + + coin_specific_coind_args = [] + coin_specific_cli_args = [] + coin_specific_shared_args = [] + + usr_coind_args = [] + usr_cli_args = [] + usr_shared_args = [] + + usr_rpc_port = None + + def __new__(cls,network_id,datadir,desc='test suite daemon',rpc_port=None): + assert network_id in cls.network_ids, '{!r}: invalid network ID'.format(network_id) + assert os.path.isabs(datadir), '{!r}: invalid datadir (not an absolute path)'.format(datadir) + + if network_id.endswith('_tn'): + coinsym = network_id[:-3] + network = 'testnet' + else: + coinsym = network_id + network = 'mainnet' + + me = MMGenObject.__new__((BitcoinTestDaemon,MoneroTestDaemon)[coinsym=='xmr']) + + me.network_id = network_id + me.coinsym = coinsym + me.network = network + me.datadir = datadir + me.platform = g.platform + me.desc = desc + me.usr_rpc_port = rpc_port + return me + + def __init__(self,network_id,datadir,desc='test suite daemon',rpc_port=None): + + self.pidfile = '{}/{}-daemon.pid'.format(self.datadir,self.network) + + self.coin,self.coind_exec,self.cli_exec,self.conf_file = { + 'btc': ('Bitcoin', 'bitcoind', 'bitcoin-cli', 'bitcoin.conf'), + 'ltc': ('Litecoin','litecoind', 'litecoin-cli','litecoin.conf'), + 'bch': ('Bcash', 'bitcoind-abc','bitcoin-cli', 'bitcoin.conf'), + 'xmr': ('Monero', 'monerod', 'monerod', 'bitmonero.conf') + }[self.coinsym] + + self.net_desc = '{} {}'.format(self.coin,self.network) + + def exec_cmd_thread(self,cmd,check): + import threading + t = threading.Thread(target=self.exec_cmd,args=(cmd,check)) + t.daemon = True + t.start() + Msg_r(' \b') # blocks w/o this...crazy + + def exec_cmd(self,cmd,check): + cp = run(cmd,check=False,stdout=PIPE,stderr=PIPE) + if check and cp.returncode != 0: + raise MMGenCalledProcessError(cp) + return cp + + def run_cmd(self,cmd,silent=False,check=True,is_daemon=False): + if is_daemon and not silent: + msg('Starting {} {}'.format(self.net_desc,self.desc)) + + if self.debug: + msg('\nExecuting: {}'.format(' '.join(cmd))) + + if self.platform == 'win' and is_daemon: + cp = self.exec_cmd_thread(cmd,check) + else: + cp = self.exec_cmd(cmd,check) + + if cp: + out = cp.stdout.decode().rstrip() + err = cp.stderr.decode().rstrip() + if out and (self.debug or not silent): + msg(out) + if err and (self.debug or (cp.returncode and not silent)): + msg(err) + + return cp + + @property + def pid(self): + return open(self.pidfile).read().strip() if self.use_pidfile else '(unknown)' + + def cmd(self,action,*args,**kwargs): + return getattr(self,action)(*args,**kwargs) + + @property + def start_cmd(self): + return ([self.coind_exec] + + self.testnet_arg + + self.coind_args + + self.shared_args + + self.coin_specific_coind_args + + self.coin_specific_shared_args + + self.usr_coind_args + + self.usr_shared_args) + + def cli_cmd(self,*cmds): + return ([self.cli_exec] + + self.testnet_arg + + self.cli_args + + self.shared_args + + self.coin_specific_cli_args + + self.coin_specific_shared_args + + self.usr_cli_args + + self.usr_shared_args + + list(cmds)) + + def do_start(self,silent=False): + return self.run_cmd(self.start_cmd,silent=silent,is_daemon=True) + + def cli(self,*cmds,silent=False,check=True): + return self.run_cmd(self.cli_cmd(*cmds),silent=silent,check=check) + + def start(self,silent=False): + if self.is_ready: + if not silent: + m = '{} {} already running with pid {}' + msg(m.format(self.net_desc,self.desc,self.pid)) + else: + os.makedirs(self.datadir,exist_ok=True) + if self.conf_file: + open('{}/{}'.format(self.datadir,self.conf_file),'w').write(self.cfg_file_hdr) + ret = self.do_start(silent=silent) + if self.wait: + self.wait_for_state('ready') + return ret + + def stop(self,silent=False): + if self.is_ready: + ret = self.do_stop(silent=silent) + if self.wait: + self.wait_for_state('stopped') + return ret + else: + if not silent: + msg('{} {} not running'.format(self.net_desc,self.desc)) + # rm -rf $datadir + + def wait_for_state(self,req_state): + for i in range(200): + if self.state == req_state: + return True + time.sleep(0.2) + else: + die(2,'Daemon wait timeout for {} {} exceeded'.format(self.coin,self.network)) + + @property + def is_ready(self): + return self.state == 'ready' + + @classmethod + def check_implement(cls): + m = 'required method {}() missing in class {}' + for subcls in cls.__subclasses__(): + for k in cls.subclasses_must_implement: + assert k in subcls.__dict__, m.format(k,subcls.__name__) + +class BitcoinTestDaemon(TestDaemon): + dtype = 'bitcoin' + cfg_file_hdr = '# TestDaemon config file\n' + + def __init__(self,network_id,datadir,desc='test suite daemon',rpc_port=None): + + super().__init__(network_id,datadir,desc=desc) + + if self.platform == 'win' and self.coinsym == 'bch': + self.use_pidfile = False + + if self.network=='testnet': + self.testnet_arg = ['--testnet'] + + self.shared_args = ['--datadir='+self.datadir] + if self.usr_rpc_port: + self.shared_args += ['--rpcport={}'.format(self.usr_rpc_port)] + + self.coind_args = ['--listen=0','--keypool=1'] + if self.use_pidfile: + self.coind_args += ['--pid='+self.pidfile] + + if self.platform == 'linux': + self.coind_args += ['--daemon'] + + if self.coinsym == 'bch': + port = self.usr_rpc_port or (8442,18442)[self.network=='testnet'] + self.coin_specific_coind_args = [ + '--rpcallowip=127.0.0.1', + '--rpcbind=127.0.0.1:{}'.format(port), + '--usecashaddr=0' ] + if not self.usr_rpc_port: + self.coin_specific_cli_args = ['--rpcport={}'.format(port)] + elif self.coinsym == 'ltc': + self.coin_specific_coind_args = ['--mempoolreplacement=1'] + + @property + def state(self): + cp = self.cli('getblockcount',silent=True,check=False) + err = cp.stderr.decode() + if ("error: couldn't connect" in err + or "error: Could not connect" in err + or "does not exist" in err ): + return 'stopped' + elif cp.returncode == 0: + return 'ready' + else: + return 'busy' + + @property + def stop_cmd(self): + return self.cli_cmd('stop') + + def do_stop(self,silent=False): + if not silent: + msg('Stopping {} {}'.format(self.net_desc,self.desc)) + return self.cli('stop',silent=True) + +class MoneroTestDaemon(TestDaemon): + dtype = 'monero' + + def __init__(self,network_id,datadir,desc='test suite daemon',rpc_port=None): + + super().__init__(network_id,datadir,desc=desc) + + self.shared_args = ['--zmq-rpc-bind-port=18182','--rpc-bind-port=18181'] + self.coind_args = [ '--bg-mining-enable', + '--pidfile='+self.pidfile, + '--data-dir='+self.datadir, + '--detach', + '--offline' ] + + @property + def state(self): + cp = self.run_cmd( + [self.coind_exec] + + self.shared_args + + ['status'], + silent=True, + check=False ) + return 'stopped' if 'Error:' in cp.stdout.decode() else 'ready' + + def do_start(self,silent=False): + return super().do_start(silent=silent) + + @property + def stop_cmd(self): + return [self.coind_exec] + self.shared_args + ['exit'] + + def do_stop(self,silent=False): + return self.run_cmd(self.stop_cmd,silent=silent) + +TestDaemon.check_implement() diff --git a/setup.py b/setup.py index 4cdacf21..638a03d3 100755 --- a/setup.py +++ b/setup.py @@ -113,6 +113,7 @@ setup( 'mmgen.seed', 'mmgen.sha2', 'mmgen.term', + 'mmgen.test_daemon', 'mmgen.tool', 'mmgen.tw', 'mmgen.tx',