nucypher/cli/main.py

750 lines
28 KiB
Python
Executable File

#!/usr/bin/env python3
import logging
import os
import random
import sys
import click
import shutil
import subprocess
from constant_sorrow import constants
from twisted.internet import reactor
from nucypher.blockchain.eth.actors import Miner
from nucypher.blockchain.eth.agents import MinerAgent, PolicyAgent, NucypherTokenAgent
from nucypher.blockchain.eth.chains import Blockchain
from nucypher.blockchain.eth.deployers import NucypherTokenDeployer, MinerEscrowDeployer, PolicyManagerDeployer
from nucypher.config.characters import UrsulaConfiguration
from nucypher.config.constants import BASE_DIR
from nucypher.config.node import NodeConfiguration
from nucypher.config.utils import validate_configuration_file
from nucypher.utilities.sandbox.blockchain import TesterBlockchain, token_airdrop
from nucypher.utilities.sandbox.ursula import UrsulaProcessProtocol
__version__ = '0.1.0-alpha.0'
BANNER = """
_
| |
_ __ _ _ ___ _ _ _ __ | |__ ___ _ __
| '_ \| | | |/ __| | | | '_ \| '_ \ / _ \ '__|
| | | | |_| | (__| |_| | |_) | | | | __/ |
|_| |_|\__,_|\___|\__, | .__/|_| |_|\___|_|
__/ | |
|___/|_|
version {}
""".format(__version__)
#
# Setup Logging
#
root = logging.getLogger()
root.setLevel(logging.DEBUG)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
root.addHandler(ch)
#
# CLI Configuration
#
class NucypherClickConfig:
def __init__(self):
# NodeConfiguration.from_config_file(filepath=DEFAULT_CONFIG_FILE_LOCATION)
self.node_config = constants.NO_NODE_CONFIGURATION
# Blockchain connection contract agency
self.accounts = constants.NO_BLOCKCHAIN_CONNECTION
self.blockchain = constants.NO_BLOCKCHAIN_CONNECTION
self.registry_filepath = constants.NO_BLOCKCHAIN_CONNECTION
self.token_agent = constants.NO_BLOCKCHAIN_CONNECTION
self.miner_agent = constants.NO_BLOCKCHAIN_CONNECTION
self.policy_agent = constants.NO_BLOCKCHAIN_CONNECTION
def connect_to_blockchain(self):
"""Initialize all blockchain entities from parsed config values"""
if self.node_config is constants.NO_NODE_CONFIGURATION:
raise RuntimeError("No node configuration is available")
self.blockchain = Blockchain.from_config(config=self.node_config)
self.accounts = self.blockchain.interface.w3.eth.accounts
if self.node_config.deploy:
self.blockchain.interface.deployer_address = self.accounts[0]
def connect_to_contracts(self, simulation: bool=False):
"""Initialize contract agency and set them on config"""
if simulation is True:
# TODO: Public API for mirroring existing registry
self.blockchain.interface._registry._swap_registry(filepath=self.sim_registry_filepath)
self.token_agent = NucypherTokenAgent(blockchain=self.blockchain)
self.miner_agent = MinerAgent(token_agent=self.token_agent)
self.policy_agent = PolicyAgent(miner_agent=self.miner_agent)
uses_config = click.make_pass_decorator(NucypherClickConfig, ensure=True)
@click.group()
@click.option('--version', help="Prints the installed version.", is_flag=True)
@click.option('--verbose', help="Enable verbose mode.", is_flag=True)
@click.option('--config-file', help="Specify a custom config filepath.", type=click.Path(), default="cool winnebago")
@uses_config
def cli(config, verbose, version, config_file):
"""Configure and manage a nucypher nodes"""
# validate_nucypher_ini_config(filepath=config_file)
click.echo(BANNER)
# Store config data
config.verbose = verbose
config.config_filepath = config_file
if config.verbose:
click.echo("Running in verbose mode...")
if version:
click.echo("Version {}".format(__version__))
@cli.command()
@click.argument('action')
@click.option('--temp', is_flag=True, default=False)
@click.option('--filesystem', is_flag=True, default=False)
@click.option('--config-file', help="Specify a custom .ini configuration filepath")
@click.option('--config-root', help="Specify a custom installation location")
@uses_config
def configure(config, action, config_file, config_root, temp, filesystem):
"""Manage the nucypher .ini configuration file"""
def __destroy(configuration):
if temp:
raise NodeConfiguration.ConfigurationError("Cannot destroy a temporary node configuration")
click.confirm("Permanently destroy all nucypher files, configurations, known nodes, certificates and keys?", abort=True)
shutil.rmtree(configuration.config_root, ignore_errors=True)
click.echo("Deleted configuration files at {}".format(node_configuration.config_root))
def __initialize(configuration):
if temp:
click.echo("Using temporary storage area")
click.confirm("Initialize new nucypher configuration?", abort=True)
configuration.write_defaults()
click.echo("Created configuration files at {}".format(node_configuration.config_root))
if config_root:
node_configuration = NodeConfiguration(temp=False,
config_root=config_root,
auto_initialize=False)
elif temp:
node_configuration = NodeConfiguration(temp=temp, auto_initialize=False)
elif config_file:
click.echo("Using configuration file at: {}".format(config_file))
node_configuration = NodeConfiguration.from_configuration_file(filepath=config_file)
else:
node_configuration = NodeConfiguration(auto_initialize=False) # Fully Default
#
# Action switch
#
if action == "init":
__initialize(node_configuration)
elif action == "destroy":
__destroy(node_configuration)
elif action == "reset":
__destroy(node_configuration)
__initialize(node_configuration)
elif action == "validate":
is_valid = True # Until there is a reason to believe otherwise
try:
if filesystem: # Check runtime directory
is_valid = NodeConfiguration.check_config_tree_exists(config_root=node_configuration.config_root)
if config_file:
is_valid = validate_configuration_file(filepath=node_configuration.config_file_location)
except NodeConfiguration.InvalidConfiguration:
is_valid = False
finally:
result = 'Valid' if is_valid else 'Invalid'
click.echo('{} is {}'.format(node_configuration.config_root, result))
@cli.command()
@click.argument('action', default='list', required=False)
@click.option('--address', help="The account to lock/unlock instead of the default")
@uses_config
def accounts(config, action, address):
"""Manage ethereum node accounts"""
if action == 'list':
if config.accounts is constants.NO_BLOCKCHAIN_CONNECTION:
click.echo('There are no accounts configured')
else:
for index, address in enumerate(config.accounts):
if index == 0:
row = 'etherbase | {}'.format(address)
else:
row = '{} ....... | {}'.format(index, address)
click.echo(row)
elif action == 'balance':
if config.accounts is constants.NO_BLOCKCHAIN_CONNECTION:
click.echo('No blockchain connection is available')
else:
if not address:
address = config.blockchain.interface.w3.eth.accounts[0]
click.echo('No address supplied, Using the default {}'.format(address))
balance = config.token_agent.token_balance(address=address)
click.echo("Balance of {} is {}".format(address, balance))
@cli.command()
@click.argument('action', default='list', required=False)
@click.option('--address', help="Send rewarded tokens to a specific address, instead of the default.")
@click.option('--value', help="Stake value in the smallest denomination")
@click.option('--duration', help="Stake duration in periods") # TODO: lock/unlock durations
@click.option('--index', help="A specific stake index to resume")
@uses_config
def stake(config, action, address, index, value, duration):
"""
Manage active and inactive node blockchain stakes.
Arguments
==========
action - Which action to perform; The choices are:
- list: List all stakes for this node
- info: Display info about a specific stake
- start: Start the staking daemon
- confirm-activity: Manually confirm-activity for the current period
- divide-stake: Divide an existing stake
value - The quantity of tokens to stake.
periods - The duration (in periods) of the stake.
Options
========
--wallet-address - A valid ethereum checksum address to use instead of the default
--stake-index - The zero-based stake index, or stake tag for this wallet-address
"""
config.connect_to_contracts()
if not address:
for index, address in enumerate(config.accounts):
if index == 0:
row = 'etherbase (0) | {}'.format(address)
else:
row = '{} .......... | {}'.format(index, address)
click.echo(row)
click.echo("Select ethereum address")
account_selection = click.prompt("Enter 0-{}".format(len(config.accounts)), type=int)
address = config.accounts[account_selection]
if action == 'list':
live_stakes = config.miner_agent.get_all_stakes(miner_address=address)
for index, stake_info in enumerate(live_stakes):
row = '{} | {}'.format(index, stake_info)
click.echo(row)
elif action == 'init':
click.confirm("Stage a new stake?", abort=True)
live_stakes = config.miner_agent.get_all_stakes(miner_address=address)
if len(live_stakes) > 0:
raise RuntimeError("There is an existing stake for {}".format(address))
# Value
balance = config.token_agent.get_balance(address=address)
click.echo("Current balance: {}".format(balance))
value = click.prompt("Enter stake value", type=int)
# Duration
message = "Minimum duration: {} | Maximum Duration: {}".format(constants.MIN_LOCKED_PERIODS,
constants.MAX_REWARD_PERIODS)
click.echo(message)
duration = click.prompt("Enter stake duration in days", type=int)
start_period = config.miner_agent.get_current_period()
end_period = start_period + duration
# Review
click.echo("""
| Staged Stake |
Node: {address}
Value: {value}
Duration: {duration}
Start Period: {start_period}
End Period: {end_period}
""".format(address=address,
value=value,
duration=duration,
start_period=start_period,
end_period=end_period))
if not click.confirm("Is this correct?"):
# field = click.prompt("Which stake field do you want to edit?")
raise NotImplementedError
# Initialize the staged stake
config.miner_agent.deposit_tokens(amount=value, lock_periods=duration, sender_address=address)
proc_params = ['run_ursula']
processProtocol = UrsulaProcessProtocol(command=proc_params)
ursula_proc = reactor.spawnProcess(processProtocol, "nucypher-cli", proc_params)
elif action == 'resume':
"""Reconnect and resume an existing live stake"""
proc_params = ['run_ursula']
processProtocol = UrsulaProcessProtocol(command=proc_params)
ursula_proc = reactor.spawnProcess(processProtocol, "nucypher-cli", proc_params)
elif action == 'confirm-activity':
"""Manually confirm activity for the active period"""
stakes = config.miner_agent.get_all_stakes(miner_address=address)
if len(stakes) == 0:
raise RuntimeError("There are no active stakes for {}".format(address))
config.miner_agent.confirm_activity(node_address=address)
elif action == 'divide':
"""Divide an existing stake by specifying the new target value and end period"""
stakes = config.miner_agent.get_all_stakes(miner_address=address)
if len(stakes) == 0:
raise RuntimeError("There are no active stakes for {}".format(address))
if not index:
for selection_index, stake_info in enumerate(stakes):
click.echo("{} ....... {}".format(selection_index, stake_info))
index = click.prompt("Select a stake to divide", type=int)
target_value = click.prompt("Enter new target value", type=int)
extension = click.prompt("Enter number of periods to extend", type=int)
click.echo("""
Current Stake: {}
New target value {}
New end period: {}
""".format(stakes[index],
target_value,
target_value+extension))
click.confirm("Is this correct?", abort=True)
config.miner_agent.divide_stake(miner_address=address,
stake_index=index,
value=value,
periods=extension)
elif action == 'collect-reward':
"""Withdraw staking reward to the specified wallet address"""
# click.confirm("Send {} to {}?".format)
# config.miner_agent.collect_staking_reward(collector_address=address)
raise NotImplementedError
elif action == 'abort':
click.confirm("Are you sure you want to abort the staking process?", abort=True)
# os.kill(pid=NotImplemented)
raise NotImplementedError
@cli.command()
@click.argument('action')
@click.option('--federated-only', is_flag=True)
@click.option('--nodes', help="The number of nodes to simulate", type=int)
@uses_config
def simulate(config, action, nodes, federated_only):
"""
Simulate the nucypher blockchain network
Arguments
==========
action - Which action to perform; The choices are:
- start: Start a multi-process nucypher network simulation
- stop: Stop a running simulation gracefully
Options
========
--nodes - The quantity of nodes (processes) to execute during the simulation
--duration = The number of periods to run the simulation before termination
"""
if action == 'init':
# OK, Try connecting to the blockchain
click.echo("Connecting to provider endpoint")
config.connect_to_blockchain()
# Actual simulation setup logic
one_million_eth = 10 ** 6 * 10 ** 18
click.echo("Airdropping {} ETH to {} test accounts".format(one_million_eth, len(config.accounts)))
config.blockchain.ether_airdrop(amount=one_million_eth) # wei -> ether | 1 Million ETH
# Fin
click.echo("Blockchain initialized")
if action == 'deploy':
if config.simulation_running is True:
raise RuntimeError("Network simulation already running")
if not federated_only:
config.connect_to_blockchain()
click.confirm("Deploy all nucypher contracts to blockchain?", abort=True)
click.echo("Bootstrapping simulated blockchain network")
blockchain = TesterBlockchain()
# TODO: Enforce Saftey - ensure this is "fake" #
conditions = ()
assert True
# Parse addresses
etherbase, *everybody_else = blockchain.interface.w3.eth.accounts
# Deploy contracts
token_deployer = NucypherTokenDeployer(blockchain=blockchain, deployer_address=etherbase)
token_deployer.arm()
token_deployer.deploy()
token_agent = token_deployer.make_agent()
click.echo("Deployed {}:{}".format(token_agent.contract_name, token_agent.contract_address))
miner_escrow_deployer = MinerEscrowDeployer(token_agent=token_agent, deployer_address=etherbase) # TODO: Deployer secrets
miner_escrow_deployer.arm()
miner_escrow_deployer.deploy()
miner_agent = miner_escrow_deployer.make_agent()
click.echo("Deployed {}:{}".format(miner_agent.contract_name, miner_agent.contract_address))
policy_manager_deployer = PolicyManagerDeployer(miner_agent=miner_agent, deployer_address=etherbase)
policy_manager_deployer.arm()
policy_manager_deployer.deploy()
policy_agent = policy_manager_deployer.make_agent()
click.echo("Deployed {}:{}".format(policy_agent.contract_name, policy_agent.contract_address))
airdrop_amount = 1000000 * int(constants.M)
click.echo("Airdropping tokens {} to {} addresses".format(airdrop_amount, len(everybody_else)))
_receipts = token_airdrop(token_agent=token_agent,
origin=etherbase,
addresses=everybody_else,
amount=airdrop_amount)
click.echo("Connecting to deployed contracts")
config.connect_to_contracts()
# Commit the current state of deployment to a registry file.
click.echo("Writing filesystem registry")
_sim_registry_name = config.blockchain.interface._registry.commit(filepath=DEFAULT_SIMULATION_REGISTRY_FILEPATH) # TODO: Simulation config
# Fin
click.echo("Ready to simulate decentralized swarm.")
else:
click.echo("Ready to run federated swarm.")
elif action == 'swarm':
if not federated_only:
config.connect_to_blockchain()
config.connect_to_contracts(simulation=True)
localhost = 'localhost'
# Select a port range to use on localhost for sim servers
start_port, stop_port = DEFAULT_SIMULATION_PORT, DEFAULT_SIMULATION_PORT + int(nodes) # TODO: Simulation config
port_range = range(start_port, stop_port)
click.echo("Selected local ports {}-{}".format(start_port, stop_port))
for index, sim_port_number in enumerate(port_range):
#
# Parse ursula parameters
#
rest_port = sim_port_number
db_name = 'sim-{}'.format(rest_port)
cli_exec = os.path.join(BASE_DIR, 'cli', 'main.py')
python_exec = 'python'
proc_params = '''
python3 {} run_ursula --host {} --rest-port {} --db-name {}
'''.format(python_exec, cli_exec, localhost, rest_port, db_name).split()
if federated_only:
click.echo("Setting federated operating mode")
proc_params.append('--federated-only')
else:
sim_address = config.accounts[index+1]
miner = Miner(miner_agent=config.miner_agent, checksum_address=sim_address)
# stake a random amount
min_stake, balance = constants.MIN_ALLOWED_LOCKED, miner.token_balance
value = random.randint(min_stake, balance)
# for a random lock duration
min_locktime, max_locktime = constants.MIN_LOCKED_PERIODS, constants.MAX_MINTING_PERIODS
periods = random.randint(min_locktime, max_locktime)
miner.initialize_stake(amount=value, lock_periods=periods)
click.echo("{} Initialized new stake: {} tokens for {} periods".format(sim_address, value, periods))
proc_params.extend('--checksum-address {}'.format(sim_address).split())
# Spawn
click.echo("Spawning node #{}".format(index+1))
processProtocol = UrsulaProcessProtocol(command=proc_params)
cli_exec = os.path.join(BASE_DIR, 'cli', 'main.py')
ursula_proc = reactor.spawnProcess(processProtocol, cli_exec, proc_params)
config.sim_processes.append(ursula_proc)
#
# post-spawnProcess
#
# Start with some basic status data, then build on it
rest_uri = "http://{}:{}".format(localhost, rest_port)
sim_data = "Started simulated Ursula | ReST {}".format(rest_uri)
rest_uri = "{host}:{port}".format(host=localhost, port=str(sim_port_number))
sim_data.format(rest_uri)
if not federated_only:
stake_infos = tuple(config.miner_agent.get_all_stakes(miner_address=sim_address))
sim_data += '| ETH address {}'.format(sim_address)
sim_data += '| {} Active stakes '.format(len(stake_infos))
click.echo(sim_data)
config.simulation_running = True
click.echo("Starting the reactor")
try:
reactor.run()
finally:
if config.operating_mode == 'decentralized':
click.echo("Removing simulation registry")
os.remove(config.sim_registry_filepath)
click.echo("Stopping simulated Ursula processes")
for process in config.sim_processes:
os.kill(process.pid, 9)
click.echo("Killed {}".format(process))
config.simulation_running = False
click.echo("Simulation stopped")
elif action == 'stop':
# Kill the simulated ursulas TODO: read PIDs from storage?
if config.simulation_running is not True:
raise RuntimeError("Network simulation is not running")
for process in config.ursula_processes:
process.transport.signalProcess('KILL')
else:
# TODO: Confirm they are dead
config.simulation_running = False
elif action == 'status':
if not config.simulation_running:
status_message = "Simulation not running."
else:
ursula_processes = len(config.ursula_processes)
status_message = """
| Node Swarm Simulation Status |
Simulation processes .............. {}
""".format(ursula_processes)
click.echo(status_message)
elif action == 'demo':
"""Run the finnegans wake demo"""
demo_exec = os.path.join(BASE_DIR, 'cli', 'demos', 'finnegans-wake-demo.py')
process_args = [sys.executable, demo_exec]
if federated_only:
process_args.append('--federated-only')
subprocess.run(process_args, stdout=subprocess.PIPE)
@cli.command()
@click.option('--provider', help="Echo blockchain provider info", is_flag=True)
@click.option('--contracts', help="Echo nucypher smart contract info", is_flag=True)
@click.option('--network', help="Echo the network status", is_flag=True)
@uses_config
def status(config, provider, contracts, network):
"""
Echo a snapshot of live network metadata.
"""
provider_payload = """
| {chain_type} Interface |
Status ................... {connection}
Provider Type ............ {provider_type}
Etherbase ................ {etherbase}
Local Accounts ........... {accounts}
""".format(chain_type=config.blockchain.__class__.__name__,
connection='Connected' if config.blockchain.interface.is_connected else 'No Connection',
provider_type=config.blockchain.interface.provider_type,
etherbase=config.accounts[0],
accounts=len(config.accounts))
contract_payload = """
| NuCypher ETH Contracts |
Registry Path ............ {registry_filepath}
NucypherToken ............ {token}
MinerEscrow .............. {escrow}
PolicyManager ............ {manager}
""".format(registry_filepath=config.blockchain.interface.registry_filepath,
token=config.token_agent.contract_address,
escrow=config.miner_agent.contract_address,
manager=config.policy_agent.contract_address,
period=config.miner_agent.get_current_period())
network_payload = """
| Blockchain Network |
Current Period ........... {period}
Active Staking Ursulas ... {ursulas}
| Swarm |
Known Nodes ..............
Verified Nodes ...........
Phantom Nodes ............ NotImplemented
""".format(period=config.miner_agent.get_current_period(),
ursulas=config.miner_agent.get_miner_population())
subpayloads = ((provider, provider_payload),
(contracts, contract_payload),
(network, network_payload),
)
if not any(sp[0] for sp in subpayloads):
payload = ''.join(sp[1] for sp in subpayloads)
else:
payload = str()
for requested, subpayload in subpayloads:
if requested is True:
payload += subpayload
click.echo(payload)
@cli.command()
@click.option('--dev', is_flag=True, default=False)
@click.option('--federated-only', is_flag=True)
@click.option('--rest-host', type=str)
@click.option('--rest-port', type=int)
@click.option('--db-name', type=str)
@click.option('--checksum-address', type=str)
@click.option('--metadata-dir', type=click.Path())
@click.option('--config-file', type=click.Path())
def run_ursula(rest_port,
rest_host,
db_name,
checksum_address,
federated_only,
metadata_dir,
config_file,
dev
) -> None:
"""
The following procedure is required to "spin-up" an Ursula node.
1. Initialize UrsulaConfiguration
2. Initialize Ursula
3. Run TLS deployment
4. Start the staking daemon
Configurable values are first read from the configuration file,
but can be overridden (mostly for testing purposes) with inline cli options.
"""
if not dev:
click.echo("WARNING: Development mode is disabled")
temp = False
else:
click.echo("Running in development mode")
temp = True
if config_file:
ursula_config = UrsulaConfiguration.from_configuration_file(filepath=config_file)
else:
ursula_config = UrsulaConfiguration(temp=temp,
auto_initialize=temp,
rest_host=rest_host,
rest_port=rest_port,
db_name=db_name,
is_me=True,
federated_only=federated_only,
checksum_address=checksum_address,
# save_metadata=False, # TODO
load_metadata=True,
known_metadata_dir=metadata_dir,
start_learning_now=True,
abort_on_learning_error=temp)
try:
URSULA = ursula_config.produce()
URSULA.get_deployer().run() # Run TLS Deploy (Reactor)
if not URSULA.federated_only: # TODO: Resume / Init
URSULA.stake() # Start Staking Daemon
finally:
click.echo("Cleaning up temporary runtime files and directories")
ursula_config.cleanup() # TODO: Integrate with other "graceful" shutdown functionality
click.echo("Exited gracefully")
if __name__ == "__main__":
cli()