diff --git a/DevReadMe.md b/DevReadMe.md index 90aea1e2ab..cc848881f8 100644 --- a/DevReadMe.md +++ b/DevReadMe.md @@ -48,30 +48,31 @@ Currently you must specify at least one _inbound_ and one _outbound_ transport. For example: ```bash -aca-py --inbound-transport http 0.0.0.0 8000 \ - --outbound-transport http +aca-py start --inbound-transport http 0.0.0.0 8000 \ + --outbound-transport http ``` or ```bash -aca-py --inbound-transport http 0.0.0.0 8000 \ - --inbound-transport ws 0.0.0.0 8001 \ - --outbound-transport ws \ - --outbound-transport http +aca-py start --inbound-transport http 0.0.0.0 8000 \ + --inbound-transport ws 0.0.0.0 8001 \ + --outbound-transport ws \ + --outbound-transport http ``` Currently, Aries Cloud Agent Python ships with both inbound and outbound transport drivers for `http` and `websockets`. More information on how to develop your own transports will be coming soon. Most configuration parameters are provided to the the agent at startup. Refer to the section below for details on all available command-line arguments. -## Command Line Arguments +### Command Line Arguments | **argument** | **format example** | **effect** | **required** | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | | `--inbound-transport`, `-it` | `--inbound-transport http 0.0.0.0 8000` | Defines the inbound transport(s) to listen on. This parameter can be passed multiple times to create multiple interfaces. Supported internal transport types are `http` and `ws`. | `true` | | `--outbound-transport`, `-ot` | `--outbound-transport http` | Defines the outbound transport(s) to support for outgoing messages. This parameter can be passed multiple times to supoort multiple transport types. Supported internal transport types are `http` and `ws`. | `true` | | `--log-config` | `--log-config /path/to/log/config.ini` | Provides a custom [python logging config file](https://docs.python.org/3/library/logging.config.html#logging-config-fileformat) to use. By default, a [default logging config](config/default_logging_config.ini) is used. | `false` | +| `--log-file` | `--log-file /path/to/logfile` | Overrides the output destination for the root logger as defined by the log config file. | `false` | | `--log-level` | `--log-level debug` | Specifies the python log level. | `false` | | `--endpoint`, `-e` | `--endpoint https://example.com/agent-endpoint` | Specifies the endpoint for which other agents should contact this agent. This endpoint could point to a different agent if routing is configured. The endpoint is used in the formation of a connection with another agent. | `false` | | `--label`, `-l` | `--label "My Agent"` | Specifies the label for this agent. This label is publicized to other agents as part of forming a connection. | `false` | @@ -106,6 +107,16 @@ Most configuration parameters are provided to the the agent at startup. Refer to | `--protocol` | `--protocol` | Instructs the agent to load an external protocol module. | `false` | | `--webhook-url` | `--webhook-url` | Instructs the agent to send webhooks containing internal state changes to a URL. This is useful for a controller to monitor changes and prompt new behaviour using the admin API. | `false` | +### Provisioning a Wallet + +It is possible to provision an Indy wallet before running an agent to avoid passing in the wallet seed on every invocation of `aca-py start`. + +```bash +aca-py provision wallet --wallet-type indy --seed $SEED +``` + +For additional options, execute `aca-py provision --help`. + ## Developing ### Prerequisites diff --git a/aries_cloudagent/__init__.py b/aries_cloudagent/__init__.py index c5389c6d5b..cefa9c5404 100644 --- a/aries_cloudagent/__init__.py +++ b/aries_cloudagent/__init__.py @@ -1,101 +1 @@ -"""Entrypoint.""" - -import asyncio -import functools -import os -import signal - -from aiohttp import ClientSession - -from .conductor import Conductor -from .config.argparse import get_settings, parse_args -from .config.default_context import DefaultContextBuilder -from .config.logging import LoggingConfigurator -from .postgres import load_postgres_plugin - - -async def get_genesis_transactions(genesis_url: str): - """Get genesis transactions.""" - headers = {} - headers["Content-Type"] = "application/json" - async with ClientSession() as client_session: - response = await client_session.get(genesis_url, headers=headers) - genesis_txns = await response.text() - return genesis_txns - - -async def start_app(conductor: Conductor): - """Start up.""" - await conductor.setup() - await conductor.start() - - -async def shutdown_app(conductor: Conductor): - """Shut down.""" - print("\nShutting down") - await conductor.stop() - tasks = [ - task - for task in asyncio.Task.all_tasks() - if task is not asyncio.tasks.Task.current_task() - ] - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - asyncio.get_event_loop().stop() - - -def main(): - """Entrypoint.""" - args = parse_args() - settings = get_settings(args) - - # Set up logging - log_config = settings.get("log.config") - log_level = settings.get("log.level") or os.getenv("LOG_LEVEL") - LoggingConfigurator.configure(log_config, log_level) - - # Fetch genesis transactions if necessary - if not settings.get("ledger.genesis_transactions") and settings.get( - "ledger.genesis_url" - ): - loop = asyncio.get_event_loop() - settings["ledger.genesis_transactions"] = loop.run_until_complete( - get_genesis_transactions(settings["ledger.genesis_url"]) - ) - - # Load postgres plug-in if necessary - if ( - settings.get("wallet.type") - and settings.get("wallet.storage_type") == "postgres_storage" - ): - if args.wallet_storage_type == "postgres_storage": - load_postgres_plugin() - - # Support WEBHOOK_URL environment variable - webhook_url = os.environ.get("WEBHOOK_URL") - if webhook_url: - webhook_urls = list(settings.get("admin.webhook_urls") or []) - webhook_urls.append(webhook_url) - settings["admin.webhook_urls"] = webhook_urls - - # Create the Conductor instance - context_builder = DefaultContextBuilder(settings) - conductor = Conductor(context_builder) - - # Run the application - loop = asyncio.get_event_loop() - loop.add_signal_handler( - signal.SIGTERM, - functools.partial(asyncio.ensure_future, shutdown_app(conductor), loop=loop), - ) - asyncio.ensure_future(start_app(conductor), loop=loop) - - try: - loop.run_forever() - except KeyboardInterrupt: - loop.run_until_complete(shutdown_app(conductor)) - - -if __name__ == "__main__": - main() # pragma: no cover +"""Aries Cloud Agent.""" diff --git a/aries_cloudagent/commands/__init__.py b/aries_cloudagent/commands/__init__.py new file mode 100644 index 0000000000..57d6388aa3 --- /dev/null +++ b/aries_cloudagent/commands/__init__.py @@ -0,0 +1,35 @@ +"""Commands module common setup.""" + +from importlib import import_module +from typing import Sequence + + +def available_commands(): + """Index available commands.""" + return [ + {"name": "help", "summary": "Print available commands"}, + {"name": "provision", "summary": "Provision an agent"}, + {"name": "start", "summary": "Start a new agent process"}, + ] + + +def load_command(command: str): + """Load the module corresponding with a named command.""" + module = None + module_path = None + for cmd in available_commands(): + if cmd["name"] == command: + module = cmd["name"] + if "module" in cmd: + module_path = cmd["module"] + break + if module and not module_path: + module_path = f"{__package__}.{module}" + if module_path: + return import_module(module_path) + + +def run_command(command: str, argv: Sequence[str] = None): + """Execute a named command with command line arguments.""" + module = load_command(command) or load_command("help") + module.execute(argv) diff --git a/aries_cloudagent/commands/help.py b/aries_cloudagent/commands/help.py new file mode 100644 index 0000000000..84904fb496 --- /dev/null +++ b/aries_cloudagent/commands/help.py @@ -0,0 +1,23 @@ +"""Help command for indexing available commands.""" + +from argparse import ArgumentParser +from typing import Sequence + + +def execute(argv: Sequence[str] = None): + """Execute the help command.""" + from . import available_commands, load_command + + parser = ArgumentParser() + subparsers = parser.add_subparsers() + for cmd in available_commands(): + if cmd["name"] == "help": + continue + module = load_command(cmd["name"]) + subparser = subparsers.add_parser(cmd["name"], help=cmd["summary"]) + module.init_argument_parser(subparser) + parser.print_help() + + +if __name__ == "__main__": + execute() diff --git a/aries_cloudagent/commands/provision.py b/aries_cloudagent/commands/provision.py new file mode 100644 index 0000000000..555bc5c48a --- /dev/null +++ b/aries_cloudagent/commands/provision.py @@ -0,0 +1,84 @@ +"""Provision command for setting up agent settings before starting.""" + +import asyncio +from argparse import ArgumentParser +from typing import Sequence + +from ..config import argparse as arg +from ..config.default_context import DefaultContextBuilder +from ..config.util import common_config +from ..error import BaseError +from ..wallet.base import BaseWallet +from ..wallet.crypto import seed_to_did + + +class ProvisionError(BaseError): + """Base exception for provisioning errors.""" + + +def init_argument_parser(parser: ArgumentParser): + """Initialize an argument parser with the module's arguments.""" + return arg.load_argument_groups( + parser, *arg.group.get_registered(arg.CAT_PROVISION) + ) + + +async def provision(category: str, settings: dict): + """Perform provisioning.""" + context_builder = DefaultContextBuilder(settings) + context = await context_builder.build() + + if category == "wallet": + # Initialize wallet + wallet: BaseWallet = await context.inject(BaseWallet) + if wallet.type != "indy": + raise ProvisionError("Cannot provision a non-Indy wallet type") + if wallet.created: + print("Created new wallet") + else: + print("Opened existing wallet") + print("Wallet type:", wallet.type) + print("Wallet name:", wallet.name) + wallet_seed = context.settings.get("wallet.seed") + public_did_info = await wallet.get_public_did() + if public_did_info: + # If we already have a registered public did and it doesn't match + # the one derived from `wallet_seed` then we error out. + # TODO: Add a command to change public did explicitly + if wallet_seed and seed_to_did(wallet_seed) != public_did_info.did: + raise ProvisionError( + "New seed provided which doesn't match the registered" + + f" public did {public_did_info.did}" + ) + elif wallet_seed: + public_did_info = await wallet.create_public_did(seed=wallet_seed) + print("Created new public DID") + if public_did_info: + print("Public DID:", public_did_info.did) + print("Verkey:", public_did_info.verkey) + else: + print("No public DID") + + +def execute(argv: Sequence[str] = None): + """Entrypoint.""" + parser = ArgumentParser() + parser.prog += " provision" + parser.add_argument( + dest="provision_category", + type=str, + metavar=(""), + choices=["wallet"], + help="The provision command to invoke", + ) + get_settings = init_argument_parser(parser) + args = parser.parse_args(argv) + settings = get_settings(args) + common_config(settings) + + loop = asyncio.get_event_loop() + loop.run_until_complete(provision(args.provision_category, settings)) + + +if __name__ == "__main__": + execute() diff --git a/aries_cloudagent/commands/start.py b/aries_cloudagent/commands/start.py new file mode 100644 index 0000000000..b72dba912f --- /dev/null +++ b/aries_cloudagent/commands/start.py @@ -0,0 +1,87 @@ +"""Entrypoint.""" + +import asyncio +import functools +import os +import signal +from argparse import ArgumentParser +from typing import Coroutine, Sequence + +from ..conductor import Conductor +from ..config import argparse as arg +from ..config.default_context import DefaultContextBuilder +from ..config.util import common_config + + +async def start_app(conductor: Conductor): + """Start up.""" + await conductor.setup() + await conductor.start() + + +async def shutdown_app(conductor: Conductor): + """Shut down.""" + print("\nShutting down") + await conductor.stop() + + +def init_argument_parser(parser: ArgumentParser): + """Initialize an argument parser with the module's arguments.""" + return arg.load_argument_groups(parser, *arg.group.get_registered(arg.CAT_START)) + + +def execute(argv: Sequence[str] = None): + """Entrypoint.""" + parser = ArgumentParser() + parser.prog += " start" + get_settings = init_argument_parser(parser) + args = parser.parse_args(argv) + settings = get_settings(args) + common_config(settings) + + # Support WEBHOOK_URL environment variable + webhook_url = os.environ.get("WEBHOOK_URL") + if webhook_url: + webhook_urls = list(settings.get("admin.webhook_urls") or []) + webhook_urls.append(webhook_url) + settings["admin.webhook_urls"] = webhook_urls + + # Create the Conductor instance + context_builder = DefaultContextBuilder(settings) + conductor = Conductor(context_builder) + + # Run the application + run_loop(start_app(conductor), shutdown_app(conductor)) + + +def run_loop(startup: Coroutine, shutdown: Coroutine): + """Execute the application, handling signals and ctrl-c.""" + + async def done(): + """Run shutdown and clean up any outstanding tasks.""" + await shutdown + tasks = [ + task + for task in asyncio.Task.all_tasks() + if task is not asyncio.Task.current_task() + ] + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + asyncio.get_event_loop().stop() + + loop = asyncio.get_event_loop() + loop.add_signal_handler( + signal.SIGTERM, functools.partial(asyncio.ensure_future, done(), loop=loop) + ) + asyncio.ensure_future(startup, loop=loop) + + try: + loop.run_forever() + except KeyboardInterrupt: + loop.run_until_complete(done()) + + +if __name__ == "__main__": + execute() diff --git a/aries_cloudagent/commands/tests/__init__.py b/aries_cloudagent/commands/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/commands/tests/test_help.py b/aries_cloudagent/commands/tests/test_help.py new file mode 100644 index 0000000000..ecaaf4a431 --- /dev/null +++ b/aries_cloudagent/commands/tests/test_help.py @@ -0,0 +1,9 @@ +from asynctest import TestCase as AsyncTestCase +from asynctest import mock as async_mock + +from .. import help as command + + +class TestHelp(AsyncTestCase): + def test_exec_help(self): + command.execute([]) diff --git a/aries_cloudagent/commands/tests/test_provision.py b/aries_cloudagent/commands/tests/test_provision.py new file mode 100644 index 0000000000..1aac05a42a --- /dev/null +++ b/aries_cloudagent/commands/tests/test_provision.py @@ -0,0 +1,23 @@ +from asynctest import TestCase as AsyncTestCase +from asynctest import mock as async_mock +import pytest + +from .. import provision as command + + +class TestProvision(AsyncTestCase): + def test_bad_category(self): + with async_mock.patch.object( + command.ArgumentParser, "print_usage" + ) as print_usage: + with self.assertRaises(SystemExit): + command.execute([]) + print_usage.assert_called_once() + + with self.assertRaises(SystemExit): + command.execute(["bad"]) + + @pytest.mark.indy + def test_provision_wallet(self): + test_seed = "testseed000000000000000000000001" + command.execute(["wallet", "--wallet-type", "indy", "--seed", test_seed]) diff --git a/aries_cloudagent/commands/tests/test_start.py b/aries_cloudagent/commands/tests/test_start.py new file mode 100644 index 0000000000..5c7b25ba0f --- /dev/null +++ b/aries_cloudagent/commands/tests/test_start.py @@ -0,0 +1,57 @@ +from asynctest import TestCase as AsyncTestCase +from asynctest import mock as async_mock + +from .. import start as command + + +class TestStart(AsyncTestCase): + def test_bad_args(self): + with async_mock.patch.object( + command.ArgumentParser, "print_usage" + ) as print_usage: + with self.assertRaises(SystemExit): + command.execute([]) + print_usage.assert_called_once() + + with self.assertRaises(SystemExit): + command.execute(["bad"]) + + def test_exec_start(self): + with async_mock.patch.object( + command, "start_app", autospec=True + ) as start_app, async_mock.patch.object( + command, "run_loop" + ) as run_loop, async_mock.patch.object( + command, "shutdown_app", autospec=True + ) as shutdown_app: + command.execute(["-it", "http", "0.0.0.0", "80", "-ot", "http"]) + start_app.assert_called_once() + assert isinstance(start_app.call_args[0][0], command.Conductor) + shutdown_app.assert_called_once() + assert isinstance(shutdown_app.call_args[0][0], command.Conductor) + run_loop.assert_called_once() + + async def test_run_loop(self): + startup = async_mock.CoroutineMock() + startup_call = startup() + shutdown = async_mock.CoroutineMock() + shutdown_call = shutdown() + with async_mock.patch.object(command, "asyncio", autospec=True) as mock_asyncio: + command.run_loop(startup_call, shutdown_call) + mock_asyncio.get_event_loop.return_value.add_signal_handler.assert_called_once() + mock_asyncio.ensure_future.assert_called_once_with( + startup_call, loop=mock_asyncio.get_event_loop.return_value + ) + mock_asyncio.get_event_loop.return_value.run_forever.assert_called_once() + + done_calls = ( + mock_asyncio.get_event_loop.return_value.add_signal_handler.call_args + ) + done_calls[0][1]() # exec partial + done_coro = mock_asyncio.ensure_future.call_args[0][0] + task = async_mock.MagicMock() + mock_asyncio.gather = async_mock.CoroutineMock() + mock_asyncio.Task.all_tasks.return_value = [task] + mock_asyncio.Task.current_task.return_value = task + await done_coro + shutdown.assert_awaited_once() diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py index 0993473b94..63b29b8d0f 100644 --- a/aries_cloudagent/config/argparse.py +++ b/aries_cloudagent/config/argparse.py @@ -1,393 +1,533 @@ """Command line option parsing.""" +import abc import os -import argparse -from typing import Sequence + +from argparse import ArgumentParser, Namespace +from typing import Type from .error import ArgsParseError -PARSER = argparse.ArgumentParser(description="Runs an Aries Cloud Agent.") - - -PARSER.add_argument( - "-it", - "--inbound-transport", - dest="inbound_transports", - type=str, - action="append", - nargs=3, - required=True, - metavar=("", "", ""), - help="Choose which interface(s) to listen on", -) - -PARSER.add_argument( - "-ot", - "--outbound-transport", - dest="outbound_transports", - type=str, - action="append", - required=True, - metavar="", - help="Choose which outbound transport handlers to register", -) - -PARSER.add_argument( - "--log-config", - dest="log_config", - type=str, - metavar="", - default=None, - help="Specifies a custom logging configuration file", -) - -PARSER.add_argument( - "--log-level", - dest="log_level", - type=str, - metavar="", - default=None, - help="Specifies a custom logging level " - + "(debug, info, warning, error, critical)", -) - -PARSER.add_argument( - "-e", - "--endpoint", - type=str, - metavar="", - help="Specify the default endpoint to use when " - + "creating connection invitations and requests", -) - -PARSER.add_argument( - "-l", - "--label", - type=str, - metavar="