Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

testserver: Standalone Greenlight testserver for non-python projects #539

Merged
merged 4 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ nav:
- Client Libraries: reference/client-libraries.md
- Credentials: reference/creds.md
- reference/node-domain.md
- Testserver: reference/testserver.md
- Certificates: reference/certs.md
- Security: reference/security.md
- LSP Integration: reference/lsp.md
Expand Down
111 changes: 111 additions & 0 deletions docs/src/reference/testserver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# The `gl-testserver`

The `gl-testserver` is a standalone version, of the `gl-testing`
framework, allowing you to test against a mock Greenlight server,
independently of your programming language and development
environment.

The goal of the `gl-testing` package is to enable developers to test
locally against a mock Greenlight server. This has a number of
advantages:

- **Speed**: by not involving the network, you can test without
latency slowing you down. The tests also run on a `regtest`
network, allowing you to generate blocks and confirm transactions
without the need to wait.
- **Cost**: the `prod` network is not free, and since tests may
consume arbitrary resources, which are then not cleaned up (see
next point), repeatedly running them could incur costs. We see this
as a bad incentive to minimize testing during development, and
`gl-testing` allows you to use only local resources that can then
also be reclaimed, making testing free, and hopefully encouraging
to test more.
- **Reproducibility**: The `prod` network does not allow cleaning up
test resources, since there might be an actual user using
them. This means that test artifacts persist between runs,
resulting in a non-reproducible environment for
testing. `gl-testing` running locally allows cleaning up resources,
thus enabling reproducible tests.

However, the downside of `gl-testing` is that is coupled with `python`
as programming language and `pytest` as test runner. This is where
`gl-testserver` comes in: by bundling all the fixtures and startup
logic into a standalone binary we can pull up an instance in a matter
of seconds, test and develop against it, and then tear it down at the
end of our session.

## How to use `gl-testserver`

It's probably easiest to use `uv` to run the script from the source
tree. Please see the [`uv` installation instructions][uv/install] for
how to get started installing `uv` then come back here.

Executing `uv run gltestserver` is the entrypoint for the tool:

```bash
gltestserver
Usage: gltestserver [OPTIONS] COMMAND [ARGS]...

Options:
--help Show this message and exit.

Commands:
run Start a gl-testserver instance to test against.
```

Currently there is only the `run` subcommand which will start the
testserver, binding the scheduler GRPC interface, the `bitcoind` RPC
interface, and the GRPC-web proxy to random ports on `localhost`.


```bash
gltestserver run --help
Usage: gltestserver run [OPTIONS]

Start a gl-testserver instance to test against.

Options:
--directory PATH Set the top-level directory for the testserver. This can
be used to run multiple instances isolated from each
other, by giving each isntance a different top-level
directory. Defaults to '/tmp/'
--help Show this message and exit
```

In order to identify the ports for the current instance you can either
see the end of the output of the command, which contains
pretty-printed key-value pairs, or load the `metadata.json` file
containing the port references to use from the `gl-testserver`
sub-directory (`/tmp/gl-testserver` if you haven't specified the
`--directory` option).

!!! note "Running multiple tests in parallel"
As the help text above already points out it is possible to run as many
instances of the testserver concurrently as you want, by specifying
separate `--directory` options in each call.

This is particularly useful if you want to run multiple tests in parallel
to speed up the test runs. It is also the core reason why ports are
randomized rather than using fixed ports per interface, as concurrent
instances would conflict, and the isolation between tests could be
compromised.

Once started you will see the following lines printed:

```
Writing testserver metadata to /tmp/gl-testserver/metadata.json
{
'scheduler_grpc_uri': 'https://localhost:38209',
'grpc_web_proxy_uri': 'http://localhost:35911',
'bitcoind_rpc_uri': 'http://rpcuser:rpcpass@localhost:44135'
}
Server is up and running with the above config values. To stop press Ctrl-C.
```

At this point you can use the URIs that are printed to interact with
the services, or use `Ctrl-C` to stop the server. When running in a
test environment you can send `SIGTERM` to the process and it'll also
shut down gracefully, cleaning up the processes, but leaving the data
created during the test in the directory.

[uv/install]: https://docs.astral.sh/uv/getting-started/installation/
2 changes: 1 addition & 1 deletion libs/gl-testing/gltesting/certs.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def gencert(idpath):
for f in path:
if os.path.exists(f):
logging.info(f"Not overwriting existing file {f}")
return
return Identity.from_path(idpath)

tmpcsr = tempfile.NamedTemporaryFile(mode="w")
json.dump(mycsr, tmpcsr)
Expand Down
Empty file added libs/gl-testserver/README.md
Empty file.
Empty file.
154 changes: 154 additions & 0 deletions libs/gl-testserver/gltestserver/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from dataclasses import dataclass
from gltesting import fixtures
from inspect import isgeneratorfunction
from pathlib import Path
from pyln.testing.utils import BitcoinD
from rich.console import Console
from rich.logging import RichHandler
from rich.pretty import pprint
from typing import Any, List
import click
import gltesting
import json
import logging
import tempfile
import time


console = Console()
logging.basicConfig(
level="DEBUG",
format="%(message)s",
datefmt="[%X]",
handlers=[
RichHandler(rich_tracebacks=True, tracebacks_suppress=[click], console=console)
],
)
logger = logging.getLogger("gltestserver")


@dataclass
class TestServer:
directory: Path
bitcoind: BitcoinD
scheduler: gltesting.scheduler.Scheduler
finalizers: List[Any]
clients: gltesting.clients.Clients
grpc_web_proxy: gltesting.grpcweb.GrpcWebProxy

def stop(self):
for f in self.finalizers[::-1]:
try:
f()
except StopIteration:
continue
except Exception as e:
logger.warn(f"Unexpected exception tearing down server: {e}")

def metadata(self):
"""Construct a dict of config values for this TestServer."""
return {
"scheduler_grpc_uri": self.scheduler.grpc_addr,
"grpc_web_proxy_uri": f"http://localhost:{self.grpc_web_proxy.web_port}",
"bitcoind_rpc_uri": f"http://rpcuser:rpcpass@localhost:{self.bitcoind.rpcport}",
}


def build(base_dir: Path):
# List of teardown functions to call in reverse order.
finalizers = []

def callfixture(f, *args, **kwargs):
"""Small shim to bypass the pytest decorator."""
F = f.__pytest_wrapped__.obj

if isgeneratorfunction(F):
it = F(*args, **kwargs)
v = it.__next__()
finalizers.append(it.__next__)
return v
else:
return F(*args, **kwargs)

directory = base_dir / "gl-testserver"

cert_directory = callfixture(fixtures.cert_directory, directory)
_root_id = callfixture(fixtures.root_id, cert_directory)
_users_id = callfixture(fixtures.users_id)
nobody_id = callfixture(fixtures.nobody_id, cert_directory)
scheduler_id = callfixture(fixtures.scheduler_id, cert_directory)
_paths = callfixture(fixtures.paths)
bitcoind = callfixture(
fixtures.bitcoind,
directory=directory,
teardown_checks=None,
)
scheduler = callfixture(
fixtures.scheduler, scheduler_id=scheduler_id, bitcoind=bitcoind
)

clients = callfixture(
fixtures.clients, directory=directory, scheduler=scheduler, nobody_id=nobody_id
)

node_grpc_web_server = callfixture(
fixtures.node_grpc_web_proxy, scheduler=scheduler
)

return TestServer(
directory=directory,
bitcoind=bitcoind,
finalizers=finalizers,
scheduler=scheduler,
clients=clients,
grpc_web_proxy=node_grpc_web_server,
)


@click.group()
def cli():
pass


@cli.command()
@click.option(
"--directory",
type=click.Path(),
help="""
Set the top-level directory for the testserver. This can be used to run
multiple instances isolated from each other, by giving each isntance a
different top-level directory. Defaults to '/tmp/'
""",
)
def run(directory):
"""Start a gl-testserver instance to test against."""
if not directory:
directory = Path(tempfile.gettempdir())
else:
directory = Path(directory)

gl = build(base_dir=directory)
try:
meta = gl.metadata()
metafile = gl.directory / "metadata.json"
metafile.parent.mkdir(parents=True, exist_ok=True)
logger.debug(f"Writing testserver metadata to {metafile}")
with metafile.open(mode="w") as f:
json.dump(meta, f)

pprint(meta)
logger.info(
"Server is up and running with the above config values. To stop press Ctrl-C."
)
time.sleep(1800)
except Exception as e:
logger.warning(f"Caught exception running testserver: {e}")
pass
finally:
logger.info("Stopping gl-testserver")
# Now tear things down again.
gl.stop()


if __name__ == "__main__":
cli()
17 changes: 17 additions & 0 deletions libs/gl-testserver/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[project]
name = "gltestserver"
version = "0.1.0"
description = "A standalone test server implementing the public Greenlight interfaces"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"click>=8.1.7",
"gltesting",
"rich>=13.9.3",
]

[project.scripts]
gltestserver = 'gltestserver.__main__:cli'

[tool.uv.sources]
gltesting = { workspace = true }
Loading
Loading