From 7b43e432e6f35ffde9d454fecd834ec256fafdd7 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 01:50:56 -0600 Subject: [PATCH 01/35] Add the plugin api --- .github/workflows/test.yml | 1 + resources/plugins/__init__.py | 0 .../plugins/simln/charts/simln/.helmignore | 23 ++ .../plugins/simln/charts/simln/Chart.yaml | 5 + .../plugins/simln/charts/simln/files/sim.json | 16 + .../simln/charts/simln/templates/NOTES.txt | 1 + .../simln/charts/simln/templates/_helpers.tpl | 7 + .../charts/simln/templates/configmap.yaml | 29 ++ .../simln/charts/simln/templates/pod.yaml | 46 +++ .../plugins/simln/charts/simln/values.yaml | 14 + resources/plugins/simln/plugin.yaml | 1 + resources/plugins/simln/simln.py | 362 ++++++++++++++++++ src/warnet/constants.py | 7 + src/warnet/control.py | 3 + src/warnet/k8s.py | 64 ++++ src/warnet/main.py | 7 + src/warnet/network.py | 11 + src/warnet/plugins.py | 198 ++++++++++ src/warnet/project.py | 3 +- test/hooks_test.py | 41 ++ test/simln_test.py | 124 ++++++ 21 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 resources/plugins/__init__.py create mode 100644 resources/plugins/simln/charts/simln/.helmignore create mode 100644 resources/plugins/simln/charts/simln/Chart.yaml create mode 100644 resources/plugins/simln/charts/simln/files/sim.json create mode 100644 resources/plugins/simln/charts/simln/templates/NOTES.txt create mode 100644 resources/plugins/simln/charts/simln/templates/_helpers.tpl create mode 100644 resources/plugins/simln/charts/simln/templates/configmap.yaml create mode 100644 resources/plugins/simln/charts/simln/templates/pod.yaml create mode 100644 resources/plugins/simln/charts/simln/values.yaml create mode 100644 resources/plugins/simln/plugin.yaml create mode 100644 resources/plugins/simln/simln.py create mode 100644 src/warnet/plugins.py create mode 100755 test/hooks_test.py create mode 100755 test/simln_test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 890696754..40ccecc10 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,7 @@ jobs: - signet_test.py - scenarios_test.py - namespace_admin_test.py + - simln_test.py steps: - uses: actions/checkout@v4 - uses: azure/setup-helm@v4.2.0 diff --git a/resources/plugins/__init__.py b/resources/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/resources/plugins/simln/charts/simln/.helmignore b/resources/plugins/simln/charts/simln/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/plugins/simln/charts/simln/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/resources/plugins/simln/charts/simln/Chart.yaml b/resources/plugins/simln/charts/simln/Chart.yaml new file mode 100644 index 000000000..92f904620 --- /dev/null +++ b/resources/plugins/simln/charts/simln/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: simln +description: A Helm chart to deploy simln +version: 0.1.0 +appVersion: "0.1.0" diff --git a/resources/plugins/simln/charts/simln/files/sim.json b/resources/plugins/simln/charts/simln/files/sim.json new file mode 100644 index 000000000..a72bd29e3 --- /dev/null +++ b/resources/plugins/simln/charts/simln/files/sim.json @@ -0,0 +1,16 @@ +{ + "nodes": [ + { + "id": "tank-0000-ln", + "address": "https://tank-0004-ln:10009", + "macaroon": "/working/admin.macaroon", + "cert": "/working/tls.cert" + }, + { + "id": "tank-0001-ln", + "address": "https://tank-0005-ln:10009", + "macaroon": "/working/admin.macaroon", + "cert": "/working/tls.cert" + } + ] +} diff --git a/resources/plugins/simln/charts/simln/templates/NOTES.txt b/resources/plugins/simln/charts/simln/templates/NOTES.txt new file mode 100644 index 000000000..2d8319bde --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/NOTES.txt @@ -0,0 +1 @@ +Thank you for installing simln. diff --git a/resources/plugins/simln/charts/simln/templates/_helpers.tpl b/resources/plugins/simln/charts/simln/templates/_helpers.tpl new file mode 100644 index 000000000..a699083e5 --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/_helpers.tpl @@ -0,0 +1,7 @@ +{{- define "mychart.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "mychart.fullname" -}} +{{- printf "%s-%s" (include "mychart.name" .) .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/plugins/simln/charts/simln/templates/configmap.yaml b/resources/plugins/simln/charts/simln/templates/configmap.yaml new file mode 100644 index 000000000..ecfb3428d --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/configmap.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "mychart.fullname" . }}-data +data: + sim.json: | + {{ .Files.Get "files/sim.json" | nindent 4 }} + tls.cert: | + -----BEGIN CERTIFICATE----- + MIIB8TCCAZagAwIBAgIUJDsR6mmY+TaO9pCfjtotlbOkzJMwCgYIKoZIzj0EAwIw + MjEfMB0GA1UECgwWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAwwGd2Fy + bmV0MB4XDTI0MTExMTE2NTM1MFoXDTM0MTEwOTE2NTM1MFowMjEfMB0GA1UECgwW + bG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAwwGd2FybmV0MFkwEwYHKoZI + zj0CAQYIKoZIzj0DAQcDQgAEBVltIvaTlAQI/3FFatTqVflZuZdRJ0SmRMSJrFLP + tp0fxE7hmteSt6gjQriy90fP8j9OJXBNAjt915kLY4zVvqOBiTCBhjAOBgNVHQ8B + Af8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAd + BgNVHQ4EFgQU5d8QMrwhLgTkDjWA+eXZGz+dybUwLwYDVR0RBCgwJoIJbG9jYWxo + b3N0ggEqhwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMAoGCCqGSM49BAMCA0kAMEYC + IQDPofN0fEl5gTwCYhk3nZbjMqJhZ8BsSJ6K8XRhxr7zbwIhAPsgQCFOqUWg632O + NEO53OQ6CIqnpxSskjsFNH4ZBQOE + -----END CERTIFICATE----- + tls.key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIIcFtWTLQv5JaRRxdkPKkO98OrvgeztbZ7h8Ev/4UbE4oAoGCCqGSM49 + AwEHoUQDQgAEBVltIvaTlAQI/3FFatTqVflZuZdRJ0SmRMSJrFLPtp0fxE7hmteS + t6gjQriy90fP8j9OJXBNAjt915kLY4zVvg== + -----END EC PRIVATE KEY----- + admin.macaroon.hex: | + 0201036c6e6402f801030a1062beabbf2a614b112128afa0c0b4fdd61201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620b17be53e367290871681055d0de15587f6d1cd47d1248fe2662ae27f62cfbdc6 diff --git a/resources/plugins/simln/charts/simln/templates/pod.yaml b/resources/plugins/simln/charts/simln/templates/pod.yaml new file mode 100644 index 000000000..c933769cc --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "mychart.fullname" . }} + labels: + app: {{ include "mychart.name" . }} + mission: {{ .Values.name }} +spec: + initContainers: + - name: "init-container" + image: "busybox" + command: + - "sh" + - "-c" + args: + - > + cp /configmap/* /working; + cd /working; + cat admin.macaroon.hex | xxd -r -p > admin.macaroon + volumeMounts: + - name: {{ .Values.workingVolume.name }} + mountPath: {{ .Values.workingVolume.mountPath }} + - name: {{ .Values.configmapVolume.name }} + mountPath: {{ .Values.configmapVolume.mountPath }} + containers: + - name: {{ .Values.name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - "sh" + - "-c" + args: + - > + cd /working; + sim-cli + volumeMounts: + - name: {{ .Values.workingVolume.name }} + mountPath: {{ .Values.workingVolume.mountPath }} + - name: {{ .Values.configmapVolume.name }} + mountPath: {{ .Values.configmapVolume.mountPath }} + volumes: + - name: {{ .Values.configmapVolume.name }} + configMap: + name: {{ include "mychart.fullname" . }}-data + - name: {{ .Values.workingVolume.name }} + emptyDir: {} diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml new file mode 100644 index 000000000..838f7a542 --- /dev/null +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -0,0 +1,14 @@ +name: "simln" +image: + repository: "mplsgrant/sim-ln" + tag: "d8c165d" + pullPolicy: IfNotPresent + +workingVolume: + name: working-volume + mountPath: /working +configmapVolume: + name: configmap-volume + mountPath: /configmap + +defaultDataDir: /app/data diff --git a/resources/plugins/simln/plugin.yaml b/resources/plugins/simln/plugin.yaml new file mode 100644 index 000000000..d4ca94189 --- /dev/null +++ b/resources/plugins/simln/plugin.yaml @@ -0,0 +1 @@ +enabled: true diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py new file mode 100644 index 000000000..0d0ae7617 --- /dev/null +++ b/resources/plugins/simln/simln.py @@ -0,0 +1,362 @@ +import json +import logging +import random +from pathlib import Path +from subprocess import run +from time import sleep + +import click +from kubernetes.stream import stream + +# When we want to select pods based on their role in Warnet, we use "mission" tags. +from warnet.constants import LIGHTNING_MISSION +from warnet.k8s import ( + download, + get_default_namespace, + get_mission, + get_static_client, + wait_for_pod, +) +from warnet.plugins import _get_plugins_directory as get_plugin_directory +from warnet.process import run_command +from warnet.status import _get_tank_status as network_status + +# To make a "mission" tag for your plugin, declare it here. This can be read by the warnet logging +# system. This should match the helm file associated with this plugin. +MISSION = "simln" + +# Each pod we deploy should have a primary container. We make the name of that primary container +# explicit here. This should match the helm file associated with this plugin. +CONTAINER = MISSION + + +class SimLNError(Exception): + pass + + +log = logging.getLogger(MISSION) +log.setLevel(logging.DEBUG) +console_handler = logging.StreamHandler() +console_handler.setLevel(logging.DEBUG) +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +console_handler.setFormatter(formatter) +log.addHandler(console_handler) + + +@click.group() +def simln(): + """Commands for the SimLN plugin""" + pass + + +def warnet_register_plugin(register_command): + register_command(simln) + + +@simln.command() +def run_demo(): + """Run the SimLN Plugin demo""" + _init_network() + _fund_wallets() + _wait_for_all_ln_nodes_to_have_a_host() + log.info(warnet("bitcoin rpc tank-0000 -generate 7")) + manual_open_channels() + log.info(warnet("bitcoin rpc tank-0000 -generate 7")) + wait_for_gossip_sync(2) + log.info("done waiting") + pod_name = prepare_and_launch_activity() + log.info(pod_name) + wait_for_pod(pod_name, 60) + + +@simln.command() +def list_simln_podnames(): + """Get a list of simln pod names""" + print([pod.metadata.name for pod in get_mission("simln")]) + + +@simln.command() +def download_results(pod_name: str): + """Download SimLN results to the current directory""" + print(download(pod_name, source_path=Path("/working/results"))) + + +def prepare_and_launch_activity() -> str: + sample_activity = _get_example_activity() + log.info(f"Activity: {sample_activity}") + pod_name = _launch_activity(sample_activity) + log.info("Sent command. Done.") + return pod_name + + +def _get_example_activity() -> list[dict]: + pods = get_mission(LIGHTNING_MISSION) + try: + pod_a = pods[1].metadata.name + pod_b = pods[2].metadata.name + except Exception as err: + raise SimLNError( + "Could not access the lightning nodes needed for the example.\n Try deploying some." + ) from err + return [{"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000}] + + +@simln.command() +def get_example_activity(): + """Get an activity representing node 2 sending msat to node 3""" + print(json.dumps(_get_example_activity())) + + +def _launch_activity(activity: list[dict]) -> str: + """Launch a SimLN chart which includes the `activity`""" + random_digits = "".join(random.choices("0123456789", k=10)) + plugin_dir = get_plugin_directory() + _generate_nodes_file(activity, plugin_dir / Path("simln/charts/simln/files/sim.json")) + command = f"helm upgrade --install simln-{random_digits} {plugin_dir}/simln/charts/simln" + log.info(f"generate activity: {command}") + run_command(command) + return f"simln-simln-{random_digits}" + + +@simln.command() +@click.argument("activity", type=str) +def launch_activity(activity: str): + """Takes a SimLN Activity which is a JSON list of objects.""" + parsed_activity = json.loads(activity) + print(_launch_activity(parsed_activity)) + + +def _init_network(): + """Mine regtest coins and wait for ln nodes to come online.""" + log.info("Initializing network") + wait_for_all_tanks_status(target="running") + + warnet("bitcoin rpc tank-0000 createwallet miner") + warnet("bitcoin rpc tank-0000 -generate 110") + wait_for_predicate(lambda: int(warnet("bitcoin rpc tank-0000 getblockcount")) > 100) + + def wait_for_all_ln_rpc(): + lns = get_mission(LIGHTNING_MISSION) + for v1_pod in lns: + ln = v1_pod.metadata.name + try: + warnet(f"ln rpc {ln} getinfo") + except Exception: + log.info(f"LN node {ln} not ready for rpc yet") + return False + return True + + wait_for_predicate(wait_for_all_ln_rpc) + + +@simln.command() +def init_network(): + _init_network() + + +def _fund_wallets(): + """Fund each ln node with 10 regtest coins.""" + log.info("Funding wallets") + outputs = "" + lns = get_mission(LIGHTNING_MISSION) + for v1_pod in lns: + lnd = v1_pod.metadata.name + addr = json.loads(warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"] + outputs += f',"{addr}":10' + # trim first comma + outputs = outputs[1:] + log.info(warnet("bitcoin rpc tank-0000 sendmany '' '{" + outputs + "}'")) + log.info(warnet("bitcoin rpc tank-0000 -generate 1")) + + +@simln.command() +def fund_wallets(): + """Fund each ln node with 10 regtest coins.""" + _fund_wallets() + + +def all_ln_nodes_have_a_host() -> bool: + """Find out if each ln node has a host.""" + pods = get_mission(LIGHTNING_MISSION) + host_havers = 0 + for pod in pods: + name = pod.metadata.name + result = warnet(f"ln host {name}") + if len(result) > 1: + host_havers += 1 + return host_havers == len(pods) and host_havers != 0 + + +@simln.command() +def wait_for_all_ln_nodes_to_have_a_host(): + log.info(_wait_for_all_ln_nodes_to_have_a_host()) + + +def _wait_for_all_ln_nodes_to_have_a_host(): + wait_for_predicate(all_ln_nodes_have_a_host, timeout=10 * 60) + + +def wait_for_predicate(predicate, timeout=5 * 60, interval=5): + log.info( + f"Waiting for predicate ({predicate.__name__}) with timeout {timeout}s and interval {interval}s" + ) + while timeout > 0: + try: + if predicate(): + return + except Exception: + pass + sleep(interval) + timeout -= interval + import inspect + + raise Exception( + f"Timed out waiting for Truth from predicate: {inspect.getsource(predicate).strip()}" + ) + + +def wait_for_all_tanks_status(target: str = "running", timeout: int = 20 * 60, interval: int = 5): + """Poll the warnet server for container status. Block until all tanks are running""" + + def check_status(): + tanks = network_status() + stats = {"total": 0} + # "Probably" means all tanks are stopped and deleted + if len(tanks) == 0: + return True + for tank in tanks: + status = tank["status"] + stats["total"] += 1 + stats[status] = stats.get(status, 0) + 1 + log.info(f"Waiting for all tanks to reach '{target}': {stats}") + return target in stats and stats[target] == stats["total"] + + wait_for_predicate(check_status, timeout, interval) + + +def wait_for_gossip_sync(expected: int = 2): + """Wait for any of the ln nodes to have an `expected` number of edges.""" + log.info(f"Waiting for sync (expecting {expected})...") + current = 0 + while current < expected: + current = 0 + pods = get_mission(LIGHTNING_MISSION) + for v1_pod in pods: + node = v1_pod.metadata.name + chs = json.loads(run_command(f"warnet ln rpc {node} describegraph"))["edges"] + log.info(f"{node}: {len(chs)} channels") + current += len(chs) + sleep(1) + log.info("Synced") + + +def warnet(cmd: str = "--help"): + """Pass a `cmd` to Warnet.""" + log.info(f"Executing warnet command: {cmd}") + command = ["warnet"] + cmd.split() + proc = run(command, capture_output=True) + if proc.stderr: + raise Exception(proc.stderr.decode().strip()) + return proc.stdout.decode() + + +def _generate_nodes_file(activity: list[dict], output_file: Path = Path("nodes.json")): + nodes = [] + + for i in get_mission(LIGHTNING_MISSION): + name = i.metadata.name + node = { + "id": name, + "address": f"https://{name}:10009", + "macaroon": "/working/admin.macaroon", + "cert": "/working/tls.cert", + } + nodes.append(node) + + data = {"nodes": nodes, "activity": activity} + + with open(output_file, "w") as f: + json.dump(data, f, indent=2) + + +def manual_open_channels(): + """Manually open channels between ln nodes 1, 2, and 3""" + + def wait_for_two_txs(): + wait_for_predicate( + lambda: json.loads(warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 2 + ) + + # 0 -> 1 -> 2 + pk1 = warnet("ln pubkey tank-0001-ln") + pk2 = warnet("ln pubkey tank-0002-ln") + log.info(f"pk1: {pk1}") + log.info(f"pk2: {pk2}") + + host1 = "" + host2 = "" + + while not host1 or not host2: + if not host1: + host1 = warnet("ln host tank-0001-ln") + if not host2: + host2 = warnet("ln host tank-0002-ln") + sleep(1) + + print( + warnet( + f"ln rpc tank-0000-ln openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" + ) + ) + print( + warnet( + f"ln rpc tank-0001-ln openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" + ) + ) + + wait_for_two_txs() + + warnet("bitcoin rpc tank-0000 -generate 10") + + +def _rpc(pod, method: str, params: tuple[str, ...]) -> str: + namespace = get_default_namespace() + + sclient = get_static_client() + if params: + cmd = [method] + cmd.extend(params) + else: + cmd = [method] + resp = stream( + sclient.connect_get_namespaced_pod_exec, + pod, + namespace, + container="simln", + command=cmd, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + stdout = "" + stderr = "" + while resp.is_open(): + resp.update(timeout=1) + if resp.peek_stdout(): + stdout_chunk = resp.read_stdout() + stdout += stdout_chunk + if resp.peek_stderr(): + stderr_chunk = resp.read_stderr() + stderr += stderr_chunk + return stdout + stderr + + +@simln.command(context_settings={"ignore_unknown_options": True}) +@click.argument("pod", type=str) +@click.argument("method", type=str) +@click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments +def rpc(pod: str, method: str, params: tuple[str, ...]): + """Run commands on a pod""" + print(_rpc(pod, method, params)) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 2c29448e8..b5616fcff 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -20,6 +20,7 @@ TANK_MISSION = "tank" COMMANDER_MISSION = "commander" +PLUGIN_MISSION = "plugin" BITCOINCORE_CONTAINER = "bitcoincore" COMMANDER_CONTAINER = "commander" @@ -37,6 +38,12 @@ NAMESPACES_FILE = "namespaces.yaml" DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" +# Plugin architecture +PLUGINS_LABEL = "plugins" +PLUGIN_YAML = "plugin.yaml" +PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_LABEL) +WARNET_USER_DIR_ENV_VAR = "WARNET_USER_DIR" + # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) LND_CHART_LOCATION = str(CHARTS_DIR.joinpath("lnd")) diff --git a/src/warnet/control.py b/src/warnet/control.py index d26614a48..4c01c27ad 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -24,6 +24,7 @@ COMMANDER_CHART, COMMANDER_CONTAINER, COMMANDER_MISSION, + PLUGIN_MISSION, TANK_MISSION, ) from .k8s import ( @@ -384,8 +385,10 @@ def format_pods(pods: list[V1Pod]) -> list[str]: pod_list = [] formatted_commanders = format_pods(get_mission(COMMANDER_MISSION)) formatted_tanks = format_pods(get_mission(TANK_MISSION)) + formatted_plugins = format_pods(get_mission(PLUGIN_MISSION)) pod_list.extend(formatted_commanders) pod_list.extend(formatted_tanks) + pod_list.extend(formatted_plugins) except Exception as e: print(f"Could not fetch any pods in namespace ({namespace}): {e}") diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index d11214d04..8a1a65bce 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -1,6 +1,7 @@ import json import os import sys +import tarfile import tempfile from pathlib import Path from time import sleep @@ -60,6 +61,22 @@ def get_pod(name: str, namespace: Optional[str] = None) -> V1Pod: return sclient.read_namespaced_pod(name=name, namespace=namespace) +def get_pods_with_label(label_selector: str, namespace: Optional[str] = None) -> list[V1Pod]: + """Get a list of pods by label. + Label example: "mission=lightning" + """ + namespace = get_default_namespace_or(namespace) + v1 = get_static_client() + + try: + pods = v1.list_namespaced_pod(namespace=namespace, label_selector=label_selector) + v1_pods = [pod for pod in pods.items] + return v1_pods + except client.exceptions.ApiException as e: + print(f"Error fetching pods: {e}") + return [] + + def get_mission(mission: str) -> list[V1Pod]: pods = get_pods() crew: list[V1Pod] = [] @@ -545,3 +562,50 @@ def write_kubeconfig(kube_config: dict, kubeconfig_path: str) -> None: except Exception as e: os.remove(temp_file.name) raise K8sError(f"Error writing kubeconfig: {kubeconfig_path}") from e + + +def download( + pod_name: str, + source_path: Path, + destination_path: Path = Path("."), + namespace: Optional[str] = None, +) -> Path: + """Download the item from the `source_path` to the `destination_path`""" + + namespace = get_default_namespace_or(namespace) + + v1 = get_static_client() + + target_folder = destination_path / source_path.stem + os.makedirs(target_folder, exist_ok=True) + + command = ["tar", "cf", "-", "-C", str(source_path.parent), str(source_path.name)] + + resp = stream( + v1.connect_get_namespaced_pod_exec, + name=pod_name, + namespace=namespace, + command=command, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + + tar_file = target_folder.with_suffix(".tar") + with open(tar_file, "wb") as f: + while resp.is_open(): + resp.update(timeout=1) + if resp.peek_stdout(): + f.write(resp.read_stdout().encode("utf-8")) + if resp.peek_stderr(): + print(resp.read_stderr()) + resp.close() + + with tarfile.open(tar_file, "r") as tar: + tar.extractall(path=destination_path) + + os.remove(tar_file) + + return destination_path diff --git a/src/warnet/main.py b/src/warnet/main.py index 868147748..64887c6ee 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -8,6 +8,7 @@ from .graph import create, graph, import_network from .image import image from .ln import ln +from .plugins import load_plugins, plugins from .project import init, new, setup from .status import status from .users import auth @@ -37,6 +38,12 @@ def cli(): cli.add_command(status) cli.add_command(stop) cli.add_command(create) +cli.add_command(plugins) + + +@load_plugins +def load_early(): + pass if __name__ == "__main__": diff --git a/src/warnet/network.py b/src/warnet/network.py index a894cafc9..e6658ae8c 100644 --- a/src/warnet/network.py +++ b/src/warnet/network.py @@ -7,6 +7,7 @@ from .bitcoin import _rpc from .constants import ( NETWORK_DIR, + PLUGINS_DIR, SCENARIOS_DIR, ) from .k8s import get_mission @@ -48,6 +49,16 @@ def copy_scenario_defaults(directory: Path): ) +def copy_plugins_defaults(directory: Path): + """Create the project structure for a warnet project's scenarios""" + copy_defaults( + directory, + PLUGINS_DIR.name, + PLUGINS_DIR, + ["__pycache__", "__init__"], + ) + + def is_connection_manual(peer): # newer nodes specify a "connection_type" return bool(peer.get("connection_type") == "manual" or peer.get("addnode") is True) diff --git a/src/warnet/plugins.py b/src/warnet/plugins.py new file mode 100644 index 000000000..e91be5f16 --- /dev/null +++ b/src/warnet/plugins.py @@ -0,0 +1,198 @@ +import copy +import importlib.util +import inspect +import os +import sys +import tempfile +from pathlib import Path +from types import ModuleType +from typing import Any, Callable, Optional + +import click +import inquirer +import yaml +from inquirer.themes import GreenPassion + +from warnet.constants import ( + PLUGIN_YAML, + PLUGINS_LABEL, + WARNET_USER_DIR_ENV_VAR, +) + + +class PluginError(Exception): + pass + + +hook_registry: set[Callable[..., Any]] = set() +imported_modules: dict[str, ModuleType] = {} + + +@click.group(name=PLUGINS_LABEL) +def plugins(): + """Control plugins""" + pass + + +@plugins.command() +def ls(): + """List all available plugins and whether they are activated""" + plugin_dir = _get_plugins_directory() + if plugin_dir is None: + direct_user_to_plugin_directory_and_exit() + + for plugin, status in get_plugins_with_status(plugin_dir): + if status: + click.secho(f"{plugin.stem:<20} enabled", fg="green") + else: + click.secho(f"{plugin.stem:<20} disabled", fg="yellow") + + +@plugins.command() +@click.argument("plugin", type=str, default="") +def toggle(plugin: str): + """Toggle a plugin on or off""" + plugin_dir = _get_plugins_directory() + if plugin_dir is None: + direct_user_to_plugin_directory_and_exit() + + if plugin == "": + plugin_list = get_plugins_with_status(plugin_dir) + formatted_list = [ + f"{str(name.stem):<25} ◦ enabled: {active}" for name, active in plugin_list + ] + + plugins_tag = "plugins" + try: + q = [ + inquirer.List( + name=plugins_tag, + message="Toggle a plugin, or ctrl-c to cancel", + choices=formatted_list, + ) + ] + selected = inquirer.prompt(q, theme=GreenPassion()) + plugin = selected[plugins_tag].split("◦")[0].strip() + except TypeError: + # user cancels and `selected[plugins_tag] fails with TypeError + sys.exit(0) + + plugin_settings = read_yaml(plugin_dir / Path(plugin) / PLUGIN_YAML) + updated_settings = copy.deepcopy(plugin_settings) + updated_settings["enabled"] = not plugin_settings["enabled"] + write_yaml(updated_settings, plugin_dir / Path(plugin) / Path(PLUGIN_YAML)) + + +def load_user_modules() -> bool: + was_successful_load = False + + plugin_dir = _get_plugins_directory() + + if not plugin_dir or not plugin_dir.is_dir(): + return was_successful_load + + enabled_plugins = [plugin for plugin, enabled in get_plugins_with_status(plugin_dir) if enabled] + + if not enabled_plugins: + return was_successful_load + + # Temporarily add the directory to sys.path for imports + sys.path.insert(0, str(plugin_dir)) + + for plugin_path in enabled_plugins: + for file in plugin_path.glob("*.py"): + if file.stem not in ("__init__"): + module_name = f"{PLUGINS_LABEL}.{file.stem}" + spec = importlib.util.spec_from_file_location(module_name, file) + module = importlib.util.module_from_spec(spec) + imported_modules[module_name] = module + sys.modules[module_name] = module + spec.loader.exec_module(module) + was_successful_load = True + + # Remove the added path from sys.path + sys.path.pop(0) + return was_successful_load + + +def register_command(command): + """Register a command to the CLI.""" + from warnet.main import cli + + register = cli.commands.get(PLUGINS_LABEL) + register.add_command(command) + + +def load_plugins(fn): + load_user_modules() + for module in imported_modules.values(): + for name, func in inspect.getmembers(module, inspect.isfunction): + if name == "warnet_register_plugin": + func(register_command) + + +def _get_plugins_directory() -> Optional[Path]: + user_dir = os.getenv(WARNET_USER_DIR_ENV_VAR) + + plugin_dir = Path(user_dir) / PLUGINS_LABEL if user_dir else Path.cwd() / PLUGINS_LABEL + + if plugin_dir and plugin_dir.is_dir(): + return plugin_dir + else: + return None + + +def direct_user_to_plugin_directory_and_exit(): + click.secho("Could not determine the plugin directory location.") + click.secho( + "Solution 1: try runing this command again, but this time from your initialized warnet directory." + ) + click.secho( + "Solution 2: consider setting environment variable pointing to your Warnet project directory:" + ) + click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") + sys.exit(1) + + +def read_yaml(path: Path) -> dict: + try: + with open(path) as file: + return yaml.safe_load(file) + except FileNotFoundError as e: + raise PluginError(f"YAML file {path} not found.") from e + except yaml.YAMLError as e: + raise PluginError(f"Error parsing yaml: {e}") from e + + +def write_yaml(yaml_dict: dict, path: Path) -> None: + dir_name = os.path.dirname(path) + try: + with tempfile.NamedTemporaryFile("w", dir=dir_name, delete=False) as temp_file: + yaml.safe_dump(yaml_dict, temp_file) + os.replace(temp_file.name, path) + except Exception as e: + os.remove(temp_file.name) + raise PluginError(f"Error writing kubeconfig: {path}") from e + + +def check_if_plugin_enabled(path: Path) -> bool: + enabled = None + try: + plugin_dict = read_yaml(path / Path("plugin.yaml")) + enabled = plugin_dict.get("enabled") + except PluginError as e: + click.secho(e) + + return bool(enabled) + + +def get_plugins_with_status(plugin_dir: Optional[Path] = None) -> list[tuple[Path, bool]]: + if not plugin_dir: + plugin_dir = _get_plugins_directory() + candidates = [ + Path(os.path.join(plugin_dir, name)) + for name in os.listdir(plugin_dir) + if os.path.isdir(os.path.join(plugin_dir, name)) + ] + plugins = [plugin_dir for plugin_dir in candidates if any(plugin_dir.glob("plugin.yaml"))] + return [(plugin, check_if_plugin_enabled(plugin)) for plugin in plugins] diff --git a/src/warnet/project.py b/src/warnet/project.py index 67b063fcd..c4122d916 100644 --- a/src/warnet/project.py +++ b/src/warnet/project.py @@ -26,7 +26,7 @@ KUBECTL_DOWNLOAD_URL_STUB, ) from .graph import inquirer_create_network -from .network import copy_network_defaults, copy_scenario_defaults +from .network import copy_network_defaults, copy_plugins_defaults, copy_scenario_defaults @click.command() @@ -387,6 +387,7 @@ def create_warnet_project(directory: Path, check_empty: bool = False): try: copy_network_defaults(directory) copy_scenario_defaults(directory) + copy_plugins_defaults(directory) click.echo(f"Copied network example files to {directory}/networks") click.echo(f"Created warnet project structure in {directory}") except Exception as e: diff --git a/test/hooks_test.py b/test/hooks_test.py new file mode 100755 index 000000000..70d834fe4 --- /dev/null +++ b/test/hooks_test.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +import os +from pathlib import Path + +import pexpect +from test_base import TestBase + + +class HooksTest(TestBase): + def __init__(self): + super().__init__() + self.network_dir = Path(os.path.dirname(__file__)) / "data" / "12_node_ring" + + def run_test(self): + try: + os.chdir(self.tmpdir) + self.setup_network() + self.generate_plugin_dir() + + finally: + self.cleanup() + + def setup_network(self): + self.log.info("Setting up network") + self.log.info(self.warnet(f"deploy {self.network_dir}")) + self.wait_for_all_tanks_status(target="running") + self.wait_for_all_edges() + + def generate_plugin_dir(self): + self.log.info("Generating the plugin directroy") + self.sut = pexpect.spawn("warnet init") + self.sut.expect("Do you want to create a custom network?", timeout=10) + self.sut.sendline("n") + plugin_dir = Path(os.getcwd()) / "plugins" + assert plugin_dir.exists() + + +if __name__ == "__main__": + test = HooksTest() + test.run_test() diff --git a/test/simln_test.py b/test/simln_test.py new file mode 100755 index 000000000..e43c86cf1 --- /dev/null +++ b/test/simln_test.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +import ast +import json +import os +from functools import partial +from pathlib import Path +from time import sleep +from typing import Optional + +import pexpect +from test_base import TestBase + +from warnet.k8s import download, get_pods_with_label, pod_log, wait_for_pod +from warnet.process import run_command + +lightning_selector = "mission=lightning" + +UP = "\033[A" +DOWN = "\033[B" +ENTER = "\n" + + +class SimLNTest(TestBase): + def __init__(self): + super().__init__() + self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" + + def run_test(self): + try: + os.chdir(self.tmpdir) + self.setup_network() + self.run_plugin() + self.copy_results() + self.run_activity() + finally: + self.cleanup() + + def setup_network(self): + self.log.info("Setting up network") + self.log.info(self.warnet(f"deploy {self.network_dir}")) + self.wait_for_all_tanks_status(target="running") + + def run_plugin(self): + self.log.info("Initializing SimLN plugin...") + self.sut = pexpect.spawn("warnet init") + self.sut.expect("network", timeout=10) + self.sut.sendline("n") + self.sut.close() + + cmd = "warnet plugins simln run-demo" + self.log.info(f"Running: {cmd}") + run_command(cmd) + self.wait_for_predicate(self.found_results_remotely) + self.log.info("Ran SimLn plugin.") + + def copy_results(self): + self.log.info("Copying results") + pod = get_pods_with_label("mission=simln")[0] + self.wait_for_gossip_sync(2) + wait_for_pod(pod.metadata.name, 60) + + log_resp = pod_log(pod.metadata.name, "simln") + self.log.info(log_resp.data.decode("utf-8")) + + download(pod.metadata.name, Path("/working/results"), Path("."), pod.metadata.namespace) + self.wait_for_predicate(self.found_results_locally) + + def run_activity(self): + cmd = "warnet plugins simln get-example-activity" + self.log.info(f"Activity: {cmd}") + activity_result = run_command(cmd) + activity = json.loads(activity_result) + pod_result = run_command(f"warnet plugins simln launch-activity '{json.dumps(activity)}'") + partial_func = partial(self.found_results_remotely, pod_result.strip()) + self.wait_for_predicate(partial_func) + self.log.info("Successfully ran activity") + + def wait_for_gossip_sync(self, expected: int): + self.log.info(f"Waiting for sync (expecting {expected})...") + current = 0 + while current < expected: + current = 0 + pods = get_pods_with_label(lightning_selector) + for v1_pod in pods: + node = v1_pod.metadata.name + chs = json.loads(run_command(f"warnet ln rpc {node} describegraph"))["edges"] + self.log.info(f"{node}: {len(chs)} channels") + current += len(chs) + sleep(1) + self.log.info("Synced") + + def found_results_remotely(self, pod: Optional[str] = None) -> bool: + if pod is None: + pod_names_literal = run_command("warnet plugins simln list-simln-podnames") + pod_names = ast.literal_eval(pod_names_literal) + pod = pod_names[0] + self.log.info(f"Checking for results file in {pod}") + results_file = run_command(f"warnet plugins simln rpc {pod} ls /working/results").strip() + self.log.info(f"Results file: {results_file}") + results = run_command( + f"warnet plugins simln rpc {pod} cat /working/results/{results_file}" + ).strip() + self.log.info(results) + return results.find("Success") > 0 + + def found_results_locally(self) -> bool: + directory = "results" + self.log.info(f"Searching {directory}") + for root, _dirs, files in os.walk(Path(directory)): + for file_name in files: + file_path = os.path.join(root, file_name) + + with open(file_path) as file: + content = file.read() + if "Success" in content: + self.log.info(f"Found downloaded results in directory: {directory}.") + return True + self.log.info(f"Did not find downloaded results in directory: {directory}.") + return False + + +if __name__ == "__main__": + test = SimLNTest() + test.run_test() From c155135a826e50d3eba5e2af4ca7999d5d023d8d Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 27 Nov 2024 01:37:24 -0600 Subject: [PATCH 02/35] enable logs and status for plugins --- src/warnet/constants.py | 3 +++ src/warnet/control.py | 7 +++++-- src/warnet/plugins.py | 10 ++++++++++ src/warnet/status.py | 35 +++++++++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index b5616fcff..3c69db113 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -18,10 +18,13 @@ KUBE_INTERNAL_NAMESPACES = ["kube-node-lease", "kube-public", "kube-system", "kubernetes-dashboard"] HELM_COMMAND = "helm upgrade --install" +MISSION_TAG = "mission" TANK_MISSION = "tank" COMMANDER_MISSION = "commander" PLUGIN_MISSION = "plugin" +LIGHTNING_MISSION = "lightning" +CONTAINER_TAG = "container" BITCOINCORE_CONTAINER = "bitcoincore" COMMANDER_CONTAINER = "commander" diff --git a/src/warnet/control.py b/src/warnet/control.py index 4c01c27ad..cda202e90 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -1,4 +1,5 @@ import io +import itertools import json import os import subprocess @@ -24,7 +25,6 @@ COMMANDER_CHART, COMMANDER_CONTAINER, COMMANDER_MISSION, - PLUGIN_MISSION, TANK_MISSION, ) from .k8s import ( @@ -42,6 +42,7 @@ wait_for_pod, write_file_to_container, ) +from .plugins import get_plugin_missions, get_plugin_primary_containers from .process import run_command, stream_command console = Console() @@ -385,7 +386,8 @@ def format_pods(pods: list[V1Pod]) -> list[str]: pod_list = [] formatted_commanders = format_pods(get_mission(COMMANDER_MISSION)) formatted_tanks = format_pods(get_mission(TANK_MISSION)) - formatted_plugins = format_pods(get_mission(PLUGIN_MISSION)) + plugin_pods = [get_mission(mission) for mission in get_plugin_missions()] + formatted_plugins = format_pods(list(itertools.chain.from_iterable(plugin_pods))) pod_list.extend(formatted_commanders) pod_list.extend(formatted_tanks) pod_list.extend(formatted_plugins) @@ -414,6 +416,7 @@ def format_pods(pods: list[V1Pod]) -> list[str]: try: pod = get_pod(pod_name, namespace=namespace) eligible_container_names = [BITCOINCORE_CONTAINER, COMMANDER_CONTAINER] + eligible_container_names.extend(get_plugin_primary_containers()) available_container_names = [container.name for container in pod.spec.containers] container_name = next( ( diff --git a/src/warnet/plugins.py b/src/warnet/plugins.py index e91be5f16..f3fc8cb6a 100644 --- a/src/warnet/plugins.py +++ b/src/warnet/plugins.py @@ -14,6 +14,8 @@ from inquirer.themes import GreenPassion from warnet.constants import ( + CONTAINER_TAG, + MISSION_TAG, PLUGIN_YAML, PLUGINS_LABEL, WARNET_USER_DIR_ENV_VAR, @@ -196,3 +198,11 @@ def get_plugins_with_status(plugin_dir: Optional[Path] = None) -> list[tuple[Pat ] plugins = [plugin_dir for plugin_dir in candidates if any(plugin_dir.glob("plugin.yaml"))] return [(plugin, check_if_plugin_enabled(plugin)) for plugin in plugins] + + +def get_plugin_missions() -> list[str]: + return [getattr(module, MISSION_TAG.upper(), None) for module in imported_modules.values()] + + +def get_plugin_primary_containers() -> list[str]: + return [getattr(module, CONTAINER_TAG.upper(), None) for module in imported_modules.values()] diff --git a/src/warnet/status.py b/src/warnet/status.py index df62ed2df..3c70cb89c 100644 --- a/src/warnet/status.py +++ b/src/warnet/status.py @@ -1,3 +1,4 @@ +import itertools import sys import click @@ -8,8 +9,10 @@ from rich.text import Text from urllib3.exceptions import MaxRetryError +from .constants import COMMANDER_MISSION, TANK_MISSION from .k8s import get_mission from .network import _connected +from .plugins import get_plugin_missions @click.command() @@ -20,6 +23,7 @@ def status(): try: tanks = _get_tank_status() scenarios = _get_deployed_scenarios() + plugins = _get_plugin_status() except ConfigException as e: print(e) print( @@ -65,6 +69,20 @@ def status(): else: table.add_row("Scenario", "No active scenarios", "") + # Add a separator if there are plugins + if plugins: + table.add_row("", "", "") + + # Add plugins to the table + active_plugins = 0 + if plugins: + for plugin in plugins: + table.add_row("Plugin", plugin["name"], plugin["status"], plugin["namespace"]) + if plugin["status"] == "running" or plugin["status"] == "pending": + active_plugins += 1 + else: + table.add_row("Plugin", "No active plugins", "") + # Create a panel to wrap the table panel = Panel( table, @@ -81,12 +99,13 @@ def status(): summary = Text() summary.append(f"\nTotal Tanks: {len(tanks)}", style="bold cyan") summary.append(f" | Active Scenarios: {active}", style="bold green") + summary.append(f" | Active Plugins: {active_plugins}", style="bold green") console.print(summary) _connected(end="\r") def _get_tank_status(): - tanks = get_mission("tank") + tanks = get_mission(TANK_MISSION) return [ { "name": tank.metadata.name, @@ -97,8 +116,20 @@ def _get_tank_status(): ] +def _get_plugin_status(): + plugin_pods = [get_mission(mission) for mission in get_plugin_missions()] + return [ + { + "name": pod.metadata.name, + "status": pod.status.phase.lower(), + "namespace": pod.metadata.namespace, + } + for pod in list(itertools.chain.from_iterable(plugin_pods)) # flatten + ] + + def _get_deployed_scenarios(): - commanders = get_mission("commander") + commanders = get_mission(COMMANDER_MISSION) return [ { "name": c.metadata.name, From 927cc82f9ce11d04327f52a11c2a7b004000a004 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 27 Nov 2024 02:09:10 -0600 Subject: [PATCH 03/35] improve docs --- resources/plugins/simln/simln.py | 79 ++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 0d0ae7617..de47e67bc 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -8,7 +8,8 @@ import click from kubernetes.stream import stream -# When we want to select pods based on their role in Warnet, we use "mission" tags. +# When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" +# tag for "lightning" nodes is stored in LIGHTNING_MISSION. from warnet.constants import LIGHTNING_MISSION from warnet.k8s import ( download, @@ -21,12 +22,15 @@ from warnet.process import run_command from warnet.status import _get_tank_status as network_status -# To make a "mission" tag for your plugin, declare it here. This can be read by the warnet logging -# system. This should match the helm file associated with this plugin. +# To make a "mission" tag for your plugin, declare it using the variable name MISSION. This will +# be read by the warnet log system and status system. +# This should match the pod's "mission" value in this plugin's associated helm file. MISSION = "simln" # Each pod we deploy should have a primary container. We make the name of that primary container -# explicit here. This should match the helm file associated with this plugin. +# explicit here using the variable name CONTAINER which Warnet uses internally in its log and status +# systems. +# Again, this should match the container name provided in the associated helm file. CONTAINER = MISSION @@ -43,16 +47,22 @@ class SimLNError(Exception): log.addHandler(console_handler) +# Warnet uses a python package called "click" to manage terminal interactions with the user. +# Each plugin must declare a click "group" by decorating a function named after the plugin. +# This makes your plugin available in the plugin section of Warnet. @click.group() def simln(): """Commands for the SimLN plugin""" pass +# Make sure to register your plugin by adding the group function like so: def warnet_register_plugin(register_command): - register_command(simln) + register_command(simln) # <-- We added the group function here. +# The group function name is then used in decorators to create commands. These commands are +# available to users when the access your plugin from the command line in Warnet. @simln.command() def run_demo(): """Run the SimLN Plugin demo""" @@ -72,7 +82,7 @@ def run_demo(): @simln.command() def list_simln_podnames(): """Get a list of simln pod names""" - print([pod.metadata.name for pod in get_mission("simln")]) + print([pod.metadata.name for pod in get_mission(MISSION)]) @simln.command() @@ -89,6 +99,11 @@ def prepare_and_launch_activity() -> str: return pod_name +# When we want to use a command inside our plugin and also provide that command to the user, we like +# to create a private function whose name starts with an underscore. We also make a public function +# with the same name except that we leave off the underscore, decorate it with the command +# decorator, and also provide an instructive doc string which Warnet will display in the help +# section of the command line program. def _get_example_activity() -> list[dict]: pods = get_mission(LIGHTNING_MISSION) try: @@ -101,6 +116,7 @@ def _get_example_activity() -> list[dict]: return [{"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000}] +# Notice how the command that we make available to the user simply calls our internal command. @simln.command() def get_example_activity(): """Get an activity representing node 2 sending msat to node 3""" @@ -118,6 +134,7 @@ def _launch_activity(activity: list[dict]) -> str: return f"simln-simln-{random_digits}" +# Take note of how click expects us to explicitly declare command line arguments. @simln.command() @click.argument("activity", type=str) def launch_activity(activity: str): @@ -151,6 +168,7 @@ def wait_for_all_ln_rpc(): @simln.command() def init_network(): + """Initialize the demo network.""" _init_network() @@ -328,29 +346,32 @@ def _rpc(pod, method: str, params: tuple[str, ...]) -> str: cmd.extend(params) else: cmd = [method] - resp = stream( - sclient.connect_get_namespaced_pod_exec, - pod, - namespace, - container="simln", - command=cmd, - stderr=True, - stdin=False, - stdout=True, - tty=False, - _preload_content=False, - ) - stdout = "" - stderr = "" - while resp.is_open(): - resp.update(timeout=1) - if resp.peek_stdout(): - stdout_chunk = resp.read_stdout() - stdout += stdout_chunk - if resp.peek_stderr(): - stderr_chunk = resp.read_stderr() - stderr += stderr_chunk - return stdout + stderr + try: + resp = stream( + sclient.connect_get_namespaced_pod_exec, + pod, + namespace, + container="simln", + command=cmd, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + stdout = "" + stderr = "" + while resp.is_open(): + resp.update(timeout=1) + if resp.peek_stdout(): + stdout_chunk = resp.read_stdout() + stdout += stdout_chunk + if resp.peek_stderr(): + stderr_chunk = resp.read_stderr() + stderr += stderr_chunk + return stdout + stderr + except Exception as err: + print(f"Could not execute stream: {err}") @simln.command(context_settings={"ignore_unknown_options": True}) From 1fad8db9bf0254eac0c3ab0bfb68e67ab3e95800 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 27 Nov 2024 03:42:59 -0600 Subject: [PATCH 04/35] project: suggest setting an env var --- src/warnet/project.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/warnet/project.py b/src/warnet/project.py index c4122d916..a031dc768 100644 --- a/src/warnet/project.py +++ b/src/warnet/project.py @@ -24,6 +24,7 @@ KUBECTL_BLESSED_NAME_AND_CHECKSUMS, KUBECTL_BLESSED_VERSION, KUBECTL_DOWNLOAD_URL_STUB, + WARNET_USER_DIR_ENV_VAR, ) from .graph import inquirer_create_network from .network import copy_network_defaults, copy_plugins_defaults, copy_scenario_defaults @@ -439,6 +440,11 @@ def new_internal(directory: Path, from_init=False): click.echo("\nWhen you're ready, run the following command to deploy this network:") click.echo(f" warnet deploy {custom_network_path}") + click.secho( + "Consider setting an environment variable to make it easier to access your user directory:" + ) + click.secho(f"export {WARNET_USER_DIR_ENV_VAR}={directory}", fg="yellow") + @click.command() def init(): From 1d9fb95629731a05ae3477c897b42c9644a2c7d4 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 29 Nov 2024 23:11:35 -0600 Subject: [PATCH 05/35] add --user-dir option for plugins --- resources/plugins/simln/simln.py | 2 +- src/warnet/constants.py | 1 + src/warnet/main.py | 24 ++++++++++++++++------- src/warnet/plugins.py | 33 +++++++++++++++++++++----------- test/simln_test.py | 12 ++++++++++++ 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index de47e67bc..fea3618cb 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -18,7 +18,7 @@ get_static_client, wait_for_pod, ) -from warnet.plugins import _get_plugins_directory as get_plugin_directory +from warnet.plugins import get_plugins_directory_or as get_plugin_directory from warnet.process import run_command from warnet.status import _get_tank_status as network_status diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 3c69db113..0dfc6b338 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -42,6 +42,7 @@ DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" # Plugin architecture +USER_DIR_TAG = "user_dir" PLUGINS_LABEL = "plugins" PLUGIN_YAML = "plugin.yaml" PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_LABEL) diff --git a/src/warnet/main.py b/src/warnet/main.py index 64887c6ee..18d7b7e46 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -1,5 +1,9 @@ +from pathlib import Path + import click +from warnet.constants import USER_DIR_TAG + from .admin import admin from .bitcoin import bitcoin from .control import down, logs, run, snapshot, stop @@ -8,14 +12,25 @@ from .graph import create, graph, import_network from .image import image from .ln import ln -from .plugins import load_plugins, plugins +from .plugins import load_plugins, load_user_modules, plugins from .project import init, new, setup from .status import status from .users import auth @click.group() -def cli(): +@click.option( + "--user-dir", + type=click.Path(exists=True, file_okay=False), + help="Path to the user's Warnet project directory.", +) +@click.pass_context +def cli(ctx, user_dir: str): + ctx.ensure_object(dict) # initialize ctx object + if user_dir: + ctx.obj[USER_DIR_TAG] = Path(user_dir) + if load_user_modules(ctx.obj.get(USER_DIR_TAG)): + load_plugins() pass @@ -41,10 +56,5 @@ def cli(): cli.add_command(plugins) -@load_plugins -def load_early(): - pass - - if __name__ == "__main__": cli() diff --git a/src/warnet/plugins.py b/src/warnet/plugins.py index f3fc8cb6a..4a837855b 100644 --- a/src/warnet/plugins.py +++ b/src/warnet/plugins.py @@ -18,6 +18,7 @@ MISSION_TAG, PLUGIN_YAML, PLUGINS_LABEL, + USER_DIR_TAG, WARNET_USER_DIR_ENV_VAR, ) @@ -37,9 +38,10 @@ def plugins(): @plugins.command() -def ls(): +@click.pass_context +def ls(ctx): """List all available plugins and whether they are activated""" - plugin_dir = _get_plugins_directory() + plugin_dir = get_plugins_directory_or(ctx.obj.get(USER_DIR_TAG)) if plugin_dir is None: direct_user_to_plugin_directory_and_exit() @@ -52,9 +54,10 @@ def ls(): @plugins.command() @click.argument("plugin", type=str, default="") -def toggle(plugin: str): +@click.pass_context +def toggle(ctx, plugin: str): """Toggle a plugin on or off""" - plugin_dir = _get_plugins_directory() + plugin_dir = get_plugins_directory_or(ctx.obj.get(USER_DIR_TAG)) if plugin_dir is None: direct_user_to_plugin_directory_and_exit() @@ -85,10 +88,10 @@ def toggle(plugin: str): write_yaml(updated_settings, plugin_dir / Path(plugin) / Path(PLUGIN_YAML)) -def load_user_modules() -> bool: +def load_user_modules(path: Optional[Path] = None) -> bool: was_successful_load = False - plugin_dir = _get_plugins_directory() + plugin_dir = get_plugins_directory_or(path) if not plugin_dir or not plugin_dir.is_dir(): return was_successful_load @@ -125,15 +128,23 @@ def register_command(command): register.add_command(command) -def load_plugins(fn): - load_user_modules() +def load_plugins(): for module in imported_modules.values(): for name, func in inspect.getmembers(module, inspect.isfunction): if name == "warnet_register_plugin": func(register_command) -def _get_plugins_directory() -> Optional[Path]: +def get_plugins_directory_or(path: Optional[Path] = None) -> Optional[Path]: + """Get the plugins directory + user-provided path > environment variable > relative path + """ + if path: + if path.is_dir(): + return path / PLUGINS_LABEL + else: + click.secho(f"Not a directory: {path}", fg="red") + user_dir = os.getenv(WARNET_USER_DIR_ENV_VAR) plugin_dir = Path(user_dir) / PLUGINS_LABEL if user_dir else Path.cwd() / PLUGINS_LABEL @@ -147,7 +158,7 @@ def _get_plugins_directory() -> Optional[Path]: def direct_user_to_plugin_directory_and_exit(): click.secho("Could not determine the plugin directory location.") click.secho( - "Solution 1: try runing this command again, but this time from your initialized warnet directory." + "Solution 1: try runing this command again, but this time from your initialized Warnet directory." ) click.secho( "Solution 2: consider setting environment variable pointing to your Warnet project directory:" @@ -190,7 +201,7 @@ def check_if_plugin_enabled(path: Path) -> bool: def get_plugins_with_status(plugin_dir: Optional[Path] = None) -> list[tuple[Path, bool]]: if not plugin_dir: - plugin_dir = _get_plugins_directory() + plugin_dir = get_plugins_directory_or() candidates = [ Path(os.path.join(plugin_dir, name)) for name in os.listdir(plugin_dir) diff --git a/test/simln_test.py b/test/simln_test.py index e43c86cf1..fa80347a4 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -32,6 +32,7 @@ def run_test(self): self.run_plugin() self.copy_results() self.run_activity() + self.run_activity_with_user_dir() finally: self.cleanup() @@ -75,6 +76,17 @@ def run_activity(self): self.wait_for_predicate(partial_func) self.log.info("Successfully ran activity") + def run_activity_with_user_dir(self): + cmd = "mkdir temp; cd temp; warnet --user-dir ../ plugins simln get-example-activity; cd ../; rm -rf temp" + self.log.info(f"Activity: {cmd}") + activity_result = run_command(cmd) + activity = json.loads(activity_result) + pod_result = run_command(f"warnet plugins simln launch-activity '{json.dumps(activity)}'") + partial_func = partial(self.found_results_remotely, pod_result.strip()) + self.wait_for_predicate(partial_func) + run_command("cd ../") + self.log.info("Successfully ran activity using --user-dir") + def wait_for_gossip_sync(self, expected: int): self.log.info(f"Waiting for sync (expecting {expected})...") current = 0 From 08ad2bdaa6ea188e426193a36971125caa3f1b95 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 29 Nov 2024 23:22:26 -0600 Subject: [PATCH 06/35] clean up tags and names --- src/warnet/constants.py | 5 +++-- src/warnet/plugins.py | 29 ++++++++++++++--------------- test/hooks_test.py | 41 ----------------------------------------- 3 files changed, 17 insertions(+), 58 deletions(-) delete mode 100755 test/hooks_test.py diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 0dfc6b338..808eed68b 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -43,9 +43,10 @@ # Plugin architecture USER_DIR_TAG = "user_dir" -PLUGINS_LABEL = "plugins" +PLUGINS_TAG = "plugins" +ENABLED_TAG = "enabled" PLUGIN_YAML = "plugin.yaml" -PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_LABEL) +PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_TAG) WARNET_USER_DIR_ENV_VAR = "WARNET_USER_DIR" # Helm charts diff --git a/src/warnet/plugins.py b/src/warnet/plugins.py index 4a837855b..badeda36c 100644 --- a/src/warnet/plugins.py +++ b/src/warnet/plugins.py @@ -6,7 +6,7 @@ import tempfile from pathlib import Path from types import ModuleType -from typing import Any, Callable, Optional +from typing import Optional import click import inquirer @@ -15,9 +15,10 @@ from warnet.constants import ( CONTAINER_TAG, + ENABLED_TAG, MISSION_TAG, PLUGIN_YAML, - PLUGINS_LABEL, + PLUGINS_TAG, USER_DIR_TAG, WARNET_USER_DIR_ENV_VAR, ) @@ -27,11 +28,10 @@ class PluginError(Exception): pass -hook_registry: set[Callable[..., Any]] = set() imported_modules: dict[str, ModuleType] = {} -@click.group(name=PLUGINS_LABEL) +@click.group(name=PLUGINS_TAG) def plugins(): """Control plugins""" pass @@ -67,24 +67,23 @@ def toggle(ctx, plugin: str): f"{str(name.stem):<25} ◦ enabled: {active}" for name, active in plugin_list ] - plugins_tag = "plugins" try: q = [ inquirer.List( - name=plugins_tag, + name=PLUGINS_TAG, message="Toggle a plugin, or ctrl-c to cancel", choices=formatted_list, ) ] selected = inquirer.prompt(q, theme=GreenPassion()) - plugin = selected[plugins_tag].split("◦")[0].strip() + plugin = selected[PLUGINS_TAG].split("◦")[0].strip() except TypeError: # user cancels and `selected[plugins_tag] fails with TypeError sys.exit(0) plugin_settings = read_yaml(plugin_dir / Path(plugin) / PLUGIN_YAML) updated_settings = copy.deepcopy(plugin_settings) - updated_settings["enabled"] = not plugin_settings["enabled"] + updated_settings[ENABLED_TAG] = not plugin_settings[ENABLED_TAG] write_yaml(updated_settings, plugin_dir / Path(plugin) / Path(PLUGIN_YAML)) @@ -107,7 +106,7 @@ def load_user_modules(path: Optional[Path] = None) -> bool: for plugin_path in enabled_plugins: for file in plugin_path.glob("*.py"): if file.stem not in ("__init__"): - module_name = f"{PLUGINS_LABEL}.{file.stem}" + module_name = f"{PLUGINS_TAG}.{file.stem}" spec = importlib.util.spec_from_file_location(module_name, file) module = importlib.util.module_from_spec(spec) imported_modules[module_name] = module @@ -124,7 +123,7 @@ def register_command(command): """Register a command to the CLI.""" from warnet.main import cli - register = cli.commands.get(PLUGINS_LABEL) + register = cli.commands.get(PLUGINS_TAG) register.add_command(command) @@ -141,13 +140,13 @@ def get_plugins_directory_or(path: Optional[Path] = None) -> Optional[Path]: """ if path: if path.is_dir(): - return path / PLUGINS_LABEL + return path / PLUGINS_TAG else: click.secho(f"Not a directory: {path}", fg="red") user_dir = os.getenv(WARNET_USER_DIR_ENV_VAR) - plugin_dir = Path(user_dir) / PLUGINS_LABEL if user_dir else Path.cwd() / PLUGINS_LABEL + plugin_dir = Path(user_dir) / PLUGINS_TAG if user_dir else Path.cwd() / PLUGINS_TAG if plugin_dir and plugin_dir.is_dir(): return plugin_dir @@ -191,8 +190,8 @@ def write_yaml(yaml_dict: dict, path: Path) -> None: def check_if_plugin_enabled(path: Path) -> bool: enabled = None try: - plugin_dict = read_yaml(path / Path("plugin.yaml")) - enabled = plugin_dict.get("enabled") + plugin_dict = read_yaml(path / Path(PLUGIN_YAML)) + enabled = plugin_dict.get(ENABLED_TAG) except PluginError as e: click.secho(e) @@ -207,7 +206,7 @@ def get_plugins_with_status(plugin_dir: Optional[Path] = None) -> list[tuple[Pat for name in os.listdir(plugin_dir) if os.path.isdir(os.path.join(plugin_dir, name)) ] - plugins = [plugin_dir for plugin_dir in candidates if any(plugin_dir.glob("plugin.yaml"))] + plugins = [plugin_dir for plugin_dir in candidates if any(plugin_dir.glob(PLUGIN_YAML))] return [(plugin, check_if_plugin_enabled(plugin)) for plugin in plugins] diff --git a/test/hooks_test.py b/test/hooks_test.py deleted file mode 100755 index 70d834fe4..000000000 --- a/test/hooks_test.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import os -from pathlib import Path - -import pexpect -from test_base import TestBase - - -class HooksTest(TestBase): - def __init__(self): - super().__init__() - self.network_dir = Path(os.path.dirname(__file__)) / "data" / "12_node_ring" - - def run_test(self): - try: - os.chdir(self.tmpdir) - self.setup_network() - self.generate_plugin_dir() - - finally: - self.cleanup() - - def setup_network(self): - self.log.info("Setting up network") - self.log.info(self.warnet(f"deploy {self.network_dir}")) - self.wait_for_all_tanks_status(target="running") - self.wait_for_all_edges() - - def generate_plugin_dir(self): - self.log.info("Generating the plugin directroy") - self.sut = pexpect.spawn("warnet init") - self.sut.expect("Do you want to create a custom network?", timeout=10) - self.sut.sendline("n") - plugin_dir = Path(os.getcwd()) / "plugins" - assert plugin_dir.exists() - - -if __name__ == "__main__": - test = HooksTest() - test.run_test() From 42a4118ac9667ed050209f5a7d40e3b84f091499 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 4 Dec 2024 11:29:48 -0600 Subject: [PATCH 07/35] simln.py: use user_dir; tweak print statemnet --- resources/plugins/simln/simln.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index fea3618cb..423af38a6 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -4,13 +4,14 @@ from pathlib import Path from subprocess import run from time import sleep +from typing import Optional import click from kubernetes.stream import stream # When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" # tag for "lightning" nodes is stored in LIGHTNING_MISSION. -from warnet.constants import LIGHTNING_MISSION +from warnet.constants import LIGHTNING_MISSION, USER_DIR_TAG from warnet.k8s import ( download, get_default_namespace, @@ -18,7 +19,7 @@ get_static_client, wait_for_pod, ) -from warnet.plugins import get_plugins_directory_or as get_plugin_directory +from warnet.plugins import get_plugins_directory_or from warnet.process import run_command from warnet.status import _get_tank_status as network_status @@ -86,9 +87,11 @@ def list_simln_podnames(): @simln.command() +@click.argument("pod_name", type=str) def download_results(pod_name: str): """Download SimLN results to the current directory""" - print(download(pod_name, source_path=Path("/working/results"))) + dest = download(pod_name, source_path=Path("/working/results")) + print(f"Downloaded results to: {dest}") def prepare_and_launch_activity() -> str: @@ -123,10 +126,10 @@ def get_example_activity(): print(json.dumps(_get_example_activity())) -def _launch_activity(activity: list[dict]) -> str: +def _launch_activity(activity: list[dict], user_dir: Optional[str] = None) -> str: """Launch a SimLN chart which includes the `activity`""" random_digits = "".join(random.choices("0123456789", k=10)) - plugin_dir = get_plugin_directory() + plugin_dir = get_plugins_directory_or(user_dir) _generate_nodes_file(activity, plugin_dir / Path("simln/charts/simln/files/sim.json")) command = f"helm upgrade --install simln-{random_digits} {plugin_dir}/simln/charts/simln" log.info(f"generate activity: {command}") @@ -137,10 +140,12 @@ def _launch_activity(activity: list[dict]) -> str: # Take note of how click expects us to explicitly declare command line arguments. @simln.command() @click.argument("activity", type=str) -def launch_activity(activity: str): +@click.pass_context +def launch_activity(ctx, activity: str): """Takes a SimLN Activity which is a JSON list of objects.""" parsed_activity = json.loads(activity) - print(_launch_activity(parsed_activity)) + user_dir = ctx.obj.get(USER_DIR_TAG) + print(_launch_activity(parsed_activity, user_dir)) def _init_network(): From 7bc7ab6071aeb3f1950666fc022860359b855a36 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 4 Dec 2024 17:24:12 -0600 Subject: [PATCH 08/35] incorporate suggestions --- .../plugins/simln/charts/simln/Chart.yaml | 2 +- .../plugins/simln/charts/simln/files/sim.json | 16 ------ .../charts/simln/templates/configmap.yaml | 8 --- .../simln/charts/simln/templates/pod.yaml | 12 +++-- resources/plugins/simln/simln.py | 51 +++++++++++++------ src/warnet/k8s.py | 13 +++-- src/warnet/plugins.py | 6 +++ test/simln_test.py | 10 ++-- 8 files changed, 62 insertions(+), 56 deletions(-) delete mode 100644 resources/plugins/simln/charts/simln/files/sim.json diff --git a/resources/plugins/simln/charts/simln/Chart.yaml b/resources/plugins/simln/charts/simln/Chart.yaml index 92f904620..3df6dd232 100644 --- a/resources/plugins/simln/charts/simln/Chart.yaml +++ b/resources/plugins/simln/charts/simln/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: simln -description: A Helm chart to deploy simln +description: A Helm chart to deploy SimLN version: 0.1.0 appVersion: "0.1.0" diff --git a/resources/plugins/simln/charts/simln/files/sim.json b/resources/plugins/simln/charts/simln/files/sim.json deleted file mode 100644 index a72bd29e3..000000000 --- a/resources/plugins/simln/charts/simln/files/sim.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "nodes": [ - { - "id": "tank-0000-ln", - "address": "https://tank-0004-ln:10009", - "macaroon": "/working/admin.macaroon", - "cert": "/working/tls.cert" - }, - { - "id": "tank-0001-ln", - "address": "https://tank-0005-ln:10009", - "macaroon": "/working/admin.macaroon", - "cert": "/working/tls.cert" - } - ] -} diff --git a/resources/plugins/simln/charts/simln/templates/configmap.yaml b/resources/plugins/simln/charts/simln/templates/configmap.yaml index ecfb3428d..9688722b6 100644 --- a/resources/plugins/simln/charts/simln/templates/configmap.yaml +++ b/resources/plugins/simln/charts/simln/templates/configmap.yaml @@ -3,8 +3,6 @@ kind: ConfigMap metadata: name: {{ include "mychart.fullname" . }}-data data: - sim.json: | - {{ .Files.Get "files/sim.json" | nindent 4 }} tls.cert: | -----BEGIN CERTIFICATE----- MIIB8TCCAZagAwIBAgIUJDsR6mmY+TaO9pCfjtotlbOkzJMwCgYIKoZIzj0EAwIw @@ -19,11 +17,5 @@ data: IQDPofN0fEl5gTwCYhk3nZbjMqJhZ8BsSJ6K8XRhxr7zbwIhAPsgQCFOqUWg632O NEO53OQ6CIqnpxSskjsFNH4ZBQOE -----END CERTIFICATE----- - tls.key: | - -----BEGIN EC PRIVATE KEY----- - MHcCAQEEIIcFtWTLQv5JaRRxdkPKkO98OrvgeztbZ7h8Ev/4UbE4oAoGCCqGSM49 - AwEHoUQDQgAEBVltIvaTlAQI/3FFatTqVflZuZdRJ0SmRMSJrFLPtp0fxE7hmteS - t6gjQriy90fP8j9OJXBNAjt915kLY4zVvg== - -----END EC PRIVATE KEY----- admin.macaroon.hex: | 0201036c6e6402f801030a1062beabbf2a614b112128afa0c0b4fdd61201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620b17be53e367290871681055d0de15587f6d1cd47d1248fe2662ae27f62cfbdc6 diff --git a/resources/plugins/simln/charts/simln/templates/pod.yaml b/resources/plugins/simln/charts/simln/templates/pod.yaml index c933769cc..69790c9eb 100644 --- a/resources/plugins/simln/charts/simln/templates/pod.yaml +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -7,16 +7,20 @@ metadata: mission: {{ .Values.name }} spec: initContainers: - - name: "init-container" + - name: "init" image: "busybox" command: - "sh" - "-c" args: - > - cp /configmap/* /working; - cd /working; - cat admin.macaroon.hex | xxd -r -p > admin.macaroon + cp /configmap/* /working && + cd /working && + cat admin.macaroon.hex | xxd -r -p > admin.macaroon && + while [ ! -f /working/sim.json ]; do + echo "Waiting for /working/sim.json to exist..." + sleep 1 + done volumeMounts: - name: {{ .Values.workingVolume.name }} mountPath: {{ .Values.workingVolume.mountPath }} diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 423af38a6..d44f81b5d 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -1,6 +1,6 @@ import json import logging -import random +import time from pathlib import Path from subprocess import run from time import sleep @@ -17,7 +17,9 @@ get_default_namespace, get_mission, get_static_client, + wait_for_init, wait_for_pod, + write_file_to_container, ) from warnet.plugins import get_plugins_directory_or from warnet.process import run_command @@ -63,7 +65,7 @@ def warnet_register_plugin(register_command): # The group function name is then used in decorators to create commands. These commands are -# available to users when the access your plugin from the command line in Warnet. +# available to users when they access your plugin from the command line in Warnet. @simln.command() def run_demo(): """Run the SimLN Plugin demo""" @@ -128,13 +130,27 @@ def get_example_activity(): def _launch_activity(activity: list[dict], user_dir: Optional[str] = None) -> str: """Launch a SimLN chart which includes the `activity`""" - random_digits = "".join(random.choices("0123456789", k=10)) plugin_dir = get_plugins_directory_or(user_dir) - _generate_nodes_file(activity, plugin_dir / Path("simln/charts/simln/files/sim.json")) - command = f"helm upgrade --install simln-{random_digits} {plugin_dir}/simln/charts/simln" - log.info(f"generate activity: {command}") + + timestamp = int(time.time()) + name = f"simln-{timestamp}" + + command = f"helm upgrade --install {timestamp} {plugin_dir}/simln/charts/simln" run_command(command) - return f"simln-simln-{random_digits}" + + activity_json = _generate_activity_json(activity) + wait_for_init(name, namespace=get_default_namespace(), quiet=True) + if write_file_to_container( + name, + "init", + "/working/sim.json", + activity_json, + namespace=get_default_namespace(), + quiet=True, + ): + return name + else: + raise SimLNError(f"Could not write sim.json to the init container: {name}") # Take note of how click expects us to explicitly declare command line arguments. @@ -142,8 +158,12 @@ def _launch_activity(activity: list[dict], user_dir: Optional[str] = None) -> st @click.argument("activity", type=str) @click.pass_context def launch_activity(ctx, activity: str): - """Takes a SimLN Activity which is a JSON list of objects.""" - parsed_activity = json.loads(activity) + """Deploys a SimLN Activity which is a JSON list of objects""" + try: + parsed_activity = json.loads(activity) + except json.JSONDecodeError: + log.error("Invalid JSON input for activity.") + raise click.BadArgumentUsage("Activity must be a valid JSON string.") from None user_dir = ctx.obj.get(USER_DIR_TAG) print(_launch_activity(parsed_activity, user_dir)) @@ -283,7 +303,7 @@ def warnet(cmd: str = "--help"): return proc.stdout.decode() -def _generate_nodes_file(activity: list[dict], output_file: Path = Path("nodes.json")): +def _generate_activity_json(activity: list[dict]) -> str: nodes = [] for i in get_mission(LIGHTNING_MISSION): @@ -298,8 +318,7 @@ def _generate_nodes_file(activity: list[dict], output_file: Path = Path("nodes.j data = {"nodes": nodes, "activity": activity} - with open(output_file, "w") as f: - json.dump(data, f, indent=2) + return json.dumps(data, indent=2) def manual_open_channels(): @@ -342,7 +361,7 @@ def wait_for_two_txs(): warnet("bitcoin rpc tank-0000 -generate 10") -def _rpc(pod, method: str, params: tuple[str, ...]) -> str: +def _sh(pod, method: str, params: tuple[str, ...]) -> str: namespace = get_default_namespace() sclient = get_static_client() @@ -356,7 +375,7 @@ def _rpc(pod, method: str, params: tuple[str, ...]) -> str: sclient.connect_get_namespaced_pod_exec, pod, namespace, - container="simln", + container=CONTAINER, command=cmd, stderr=True, stdin=False, @@ -383,6 +402,6 @@ def _rpc(pod, method: str, params: tuple[str, ...]) -> str: @click.argument("pod", type=str) @click.argument("method", type=str) @click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments -def rpc(pod: str, method: str, params: tuple[str, ...]): +def sh(pod: str, method: str, params: tuple[str, ...]): """Run commands on a pod""" - print(_rpc(pod, method, params)) + print(_sh(pod, method, params)) diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 8a1a65bce..620df8132 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -319,7 +319,7 @@ def wait_for_pod_ready(name, namespace, timeout=300): return False -def wait_for_init(pod_name, timeout=300, namespace: Optional[str] = None): +def wait_for_init(pod_name, timeout=300, namespace: Optional[str] = None, quiet: bool = False): namespace = get_default_namespace_or(namespace) sclient = get_static_client() w = watch.Watch() @@ -332,10 +332,12 @@ def wait_for_init(pod_name, timeout=300, namespace: Optional[str] = None): continue for init_container_status in pod.status.init_container_statuses: if init_container_status.state.running: - print(f"initContainer in pod {pod_name} ({namespace}) is ready") + if not quiet: + print(f"initContainer in pod {pod_name} ({namespace}) is ready") w.stop() return True - print(f"Timeout waiting for initContainer in {pod_name} ({namespace})to be ready.") + if not quiet: + print(f"Timeout waiting for initContainer in {pod_name} ({namespace}) to be ready.") return False @@ -389,7 +391,7 @@ def wait_for_pod(pod_name, timeout_seconds=10, namespace: Optional[str] = None): def write_file_to_container( - pod_name, container_name, dst_path, data, namespace: Optional[str] = None + pod_name, container_name, dst_path, data, namespace: Optional[str] = None, quiet: bool = False ): namespace = get_default_namespace_or(namespace) sclient = get_static_client() @@ -421,7 +423,8 @@ def write_file_to_container( stdout=True, tty=False, ) - print(f"Successfully copied data to {pod_name}({container_name}):{dst_path}") + if not quiet: + print(f"Successfully copied data to {pod_name}({container_name}):{dst_path}") return True except Exception as e: print(f"Failed to copy data to {pod_name}({container_name}):{dst_path}:\n{e}") diff --git a/src/warnet/plugins.py b/src/warnet/plugins.py index badeda36c..da457d413 100644 --- a/src/warnet/plugins.py +++ b/src/warnet/plugins.py @@ -37,6 +37,12 @@ def plugins(): pass +@plugins.command() +def run(plugin: str): + """Run a file""" + pass + + @plugins.command() @click.pass_context def ls(ctx): diff --git a/test/simln_test.py b/test/simln_test.py index fa80347a4..0af2a7372 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -15,10 +15,6 @@ lightning_selector = "mission=lightning" -UP = "\033[A" -DOWN = "\033[B" -ENTER = "\n" - class SimLNTest(TestBase): def __init__(self): @@ -72,6 +68,7 @@ def run_activity(self): activity_result = run_command(cmd) activity = json.loads(activity_result) pod_result = run_command(f"warnet plugins simln launch-activity '{json.dumps(activity)}'") + self.log.info(f"launched activity: {pod_result}") partial_func = partial(self.found_results_remotely, pod_result.strip()) self.wait_for_predicate(partial_func) self.log.info("Successfully ran activity") @@ -82,6 +79,7 @@ def run_activity_with_user_dir(self): activity_result = run_command(cmd) activity = json.loads(activity_result) pod_result = run_command(f"warnet plugins simln launch-activity '{json.dumps(activity)}'") + self.log.info(f"launched activity: {pod_result}") partial_func = partial(self.found_results_remotely, pod_result.strip()) self.wait_for_predicate(partial_func) run_command("cd ../") @@ -107,10 +105,10 @@ def found_results_remotely(self, pod: Optional[str] = None) -> bool: pod_names = ast.literal_eval(pod_names_literal) pod = pod_names[0] self.log.info(f"Checking for results file in {pod}") - results_file = run_command(f"warnet plugins simln rpc {pod} ls /working/results").strip() + results_file = run_command(f"warnet plugins simln sh {pod} ls /working/results").strip() self.log.info(f"Results file: {results_file}") results = run_command( - f"warnet plugins simln rpc {pod} cat /working/results/{results_file}" + f"warnet plugins simln sh {pod} cat /working/results/{results_file}" ).strip() self.log.info(results) return results.find("Success") > 0 From 5ba06aa5ae50b8f1ba74b6e5d2161e4738eb7a84 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 5 Dec 2024 08:47:23 -0600 Subject: [PATCH 09/35] launch activity in one line --- resources/plugins/simln/simln.py | 222 +++---------------------------- test/ln_test.py | 12 +- test/simln_test.py | 27 ++-- 3 files changed, 41 insertions(+), 220 deletions(-) mode change 100644 => 100755 resources/plugins/simln/simln.py diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py old mode 100644 new mode 100755 index d44f81b5d..929386d20 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -1,9 +1,9 @@ +#!/usr/bin/env python3 + import json import logging import time from pathlib import Path -from subprocess import run -from time import sleep from typing import Optional import click @@ -18,12 +18,10 @@ get_mission, get_static_client, wait_for_init, - wait_for_pod, write_file_to_container, ) from warnet.plugins import get_plugins_directory_or from warnet.process import run_command -from warnet.status import _get_tank_status as network_status # To make a "mission" tag for your plugin, declare it using the variable name MISSION. This will # be read by the warnet log system and status system. @@ -54,8 +52,17 @@ class SimLNError(Exception): # Each plugin must declare a click "group" by decorating a function named after the plugin. # This makes your plugin available in the plugin section of Warnet. @click.group() -def simln(): +@click.pass_context +def simln(ctx): """Commands for the SimLN plugin""" + try: + # check if we have set a user directory + ctx.obj.get(USER_DIR_TAG) + except Exception: + # if not, set the great-grandparent of this file as the user dir + ctx.ensure_object(dict) + user_dir = Path(__file__).resolve().parent.parent.parent + ctx.obj[USER_DIR_TAG] = Path(user_dir) pass @@ -66,22 +73,6 @@ def warnet_register_plugin(register_command): # The group function name is then used in decorators to create commands. These commands are # available to users when they access your plugin from the command line in Warnet. -@simln.command() -def run_demo(): - """Run the SimLN Plugin demo""" - _init_network() - _fund_wallets() - _wait_for_all_ln_nodes_to_have_a_host() - log.info(warnet("bitcoin rpc tank-0000 -generate 7")) - manual_open_channels() - log.info(warnet("bitcoin rpc tank-0000 -generate 7")) - wait_for_gossip_sync(2) - log.info("done waiting") - pod_name = prepare_and_launch_activity() - log.info(pod_name) - wait_for_pod(pod_name, 60) - - @simln.command() def list_simln_podnames(): """Get a list of simln pod names""" @@ -96,14 +87,6 @@ def download_results(pod_name: str): print(f"Downloaded results to: {dest}") -def prepare_and_launch_activity() -> str: - sample_activity = _get_example_activity() - log.info(f"Activity: {sample_activity}") - pod_name = _launch_activity(sample_activity) - log.info("Sent command. Done.") - return pod_name - - # When we want to use a command inside our plugin and also provide that command to the user, we like # to create a private function whose name starts with an underscore. We also make a public function # with the same name except that we leave off the underscore, decorate it with the command @@ -168,141 +151,6 @@ def launch_activity(ctx, activity: str): print(_launch_activity(parsed_activity, user_dir)) -def _init_network(): - """Mine regtest coins and wait for ln nodes to come online.""" - log.info("Initializing network") - wait_for_all_tanks_status(target="running") - - warnet("bitcoin rpc tank-0000 createwallet miner") - warnet("bitcoin rpc tank-0000 -generate 110") - wait_for_predicate(lambda: int(warnet("bitcoin rpc tank-0000 getblockcount")) > 100) - - def wait_for_all_ln_rpc(): - lns = get_mission(LIGHTNING_MISSION) - for v1_pod in lns: - ln = v1_pod.metadata.name - try: - warnet(f"ln rpc {ln} getinfo") - except Exception: - log.info(f"LN node {ln} not ready for rpc yet") - return False - return True - - wait_for_predicate(wait_for_all_ln_rpc) - - -@simln.command() -def init_network(): - """Initialize the demo network.""" - _init_network() - - -def _fund_wallets(): - """Fund each ln node with 10 regtest coins.""" - log.info("Funding wallets") - outputs = "" - lns = get_mission(LIGHTNING_MISSION) - for v1_pod in lns: - lnd = v1_pod.metadata.name - addr = json.loads(warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"] - outputs += f',"{addr}":10' - # trim first comma - outputs = outputs[1:] - log.info(warnet("bitcoin rpc tank-0000 sendmany '' '{" + outputs + "}'")) - log.info(warnet("bitcoin rpc tank-0000 -generate 1")) - - -@simln.command() -def fund_wallets(): - """Fund each ln node with 10 regtest coins.""" - _fund_wallets() - - -def all_ln_nodes_have_a_host() -> bool: - """Find out if each ln node has a host.""" - pods = get_mission(LIGHTNING_MISSION) - host_havers = 0 - for pod in pods: - name = pod.metadata.name - result = warnet(f"ln host {name}") - if len(result) > 1: - host_havers += 1 - return host_havers == len(pods) and host_havers != 0 - - -@simln.command() -def wait_for_all_ln_nodes_to_have_a_host(): - log.info(_wait_for_all_ln_nodes_to_have_a_host()) - - -def _wait_for_all_ln_nodes_to_have_a_host(): - wait_for_predicate(all_ln_nodes_have_a_host, timeout=10 * 60) - - -def wait_for_predicate(predicate, timeout=5 * 60, interval=5): - log.info( - f"Waiting for predicate ({predicate.__name__}) with timeout {timeout}s and interval {interval}s" - ) - while timeout > 0: - try: - if predicate(): - return - except Exception: - pass - sleep(interval) - timeout -= interval - import inspect - - raise Exception( - f"Timed out waiting for Truth from predicate: {inspect.getsource(predicate).strip()}" - ) - - -def wait_for_all_tanks_status(target: str = "running", timeout: int = 20 * 60, interval: int = 5): - """Poll the warnet server for container status. Block until all tanks are running""" - - def check_status(): - tanks = network_status() - stats = {"total": 0} - # "Probably" means all tanks are stopped and deleted - if len(tanks) == 0: - return True - for tank in tanks: - status = tank["status"] - stats["total"] += 1 - stats[status] = stats.get(status, 0) + 1 - log.info(f"Waiting for all tanks to reach '{target}': {stats}") - return target in stats and stats[target] == stats["total"] - - wait_for_predicate(check_status, timeout, interval) - - -def wait_for_gossip_sync(expected: int = 2): - """Wait for any of the ln nodes to have an `expected` number of edges.""" - log.info(f"Waiting for sync (expecting {expected})...") - current = 0 - while current < expected: - current = 0 - pods = get_mission(LIGHTNING_MISSION) - for v1_pod in pods: - node = v1_pod.metadata.name - chs = json.loads(run_command(f"warnet ln rpc {node} describegraph"))["edges"] - log.info(f"{node}: {len(chs)} channels") - current += len(chs) - sleep(1) - log.info("Synced") - - -def warnet(cmd: str = "--help"): - """Pass a `cmd` to Warnet.""" - log.info(f"Executing warnet command: {cmd}") - command = ["warnet"] + cmd.split() - proc = run(command, capture_output=True) - if proc.stderr: - raise Exception(proc.stderr.decode().strip()) - return proc.stdout.decode() - - def _generate_activity_json(activity: list[dict]) -> str: nodes = [] @@ -321,46 +169,6 @@ def _generate_activity_json(activity: list[dict]) -> str: return json.dumps(data, indent=2) -def manual_open_channels(): - """Manually open channels between ln nodes 1, 2, and 3""" - - def wait_for_two_txs(): - wait_for_predicate( - lambda: json.loads(warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 2 - ) - - # 0 -> 1 -> 2 - pk1 = warnet("ln pubkey tank-0001-ln") - pk2 = warnet("ln pubkey tank-0002-ln") - log.info(f"pk1: {pk1}") - log.info(f"pk2: {pk2}") - - host1 = "" - host2 = "" - - while not host1 or not host2: - if not host1: - host1 = warnet("ln host tank-0001-ln") - if not host2: - host2 = warnet("ln host tank-0002-ln") - sleep(1) - - print( - warnet( - f"ln rpc tank-0000-ln openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" - ) - ) - print( - warnet( - f"ln rpc tank-0001-ln openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" - ) - ) - - wait_for_two_txs() - - warnet("bitcoin rpc tank-0000 -generate 10") - - def _sh(pod, method: str, params: tuple[str, ...]) -> str: namespace = get_default_namespace() @@ -403,5 +211,9 @@ def _sh(pod, method: str, params: tuple[str, ...]) -> str: @click.argument("method", type=str) @click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments def sh(pod: str, method: str, params: tuple[str, ...]): - """Run commands on a pod""" + """Run shell commands in a pod""" print(_sh(pod, method, params)) + + +if __name__ == "__main__": + simln() diff --git a/test/ln_test.py b/test/ln_test.py index 9a5b7c136..8bab820c8 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -6,7 +6,7 @@ from test_base import TestBase -from warnet.process import stream_command +from warnet.process import run_command, stream_command class LNTest(TestBase): @@ -15,6 +15,7 @@ def __init__(self): self.graph_file = Path(os.path.dirname(__file__)) / "data" / "LN_10.json" self.imported_network_dir = self.tmpdir / "imported_network" self.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" + self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" def run_test(self): try: @@ -22,6 +23,7 @@ def run_test(self): self.setup_network() self.test_channel_policies() self.test_payments() + self.run_simln() finally: self.cleanup() @@ -81,6 +83,14 @@ def get_and_pay(src, tgt): get_and_pay(8, 7) get_and_pay(4, 6) + def run_simln(self): + self.log.info("Running activity") + activity_cmd = f"{self.plugins_dir}/simln/simln.py get-example-activity" + activity = run_command(activity_cmd).strip() + self.log.info(f"Activity: {activity}") + command = f"{self.plugins_dir}/simln/simln.py launch-activity '{activity}'" + self.log.info(run_command(command)) + if __name__ == "__main__": test = LNTest() diff --git a/test/simln_test.py b/test/simln_test.py index 0af2a7372..e3d81edf7 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -8,6 +8,7 @@ from typing import Optional import pexpect +from ln_test import LNTest from test_base import TestBase from warnet.k8s import download, get_pods_with_label, pod_log, wait_for_pod @@ -16,40 +17,35 @@ lightning_selector = "mission=lightning" -class SimLNTest(TestBase): +class SimLNTest(LNTest, TestBase): def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" + self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" def run_test(self): try: os.chdir(self.tmpdir) + self.init_directory() + + self.import_network() self.setup_network() - self.run_plugin() + self.run_ln_init_scenario() + self.run_simln() + self.copy_results() self.run_activity() self.run_activity_with_user_dir() finally: self.cleanup() - def setup_network(self): - self.log.info("Setting up network") - self.log.info(self.warnet(f"deploy {self.network_dir}")) - self.wait_for_all_tanks_status(target="running") - - def run_plugin(self): + def init_directory(self): self.log.info("Initializing SimLN plugin...") self.sut = pexpect.spawn("warnet init") self.sut.expect("network", timeout=10) self.sut.sendline("n") self.sut.close() - cmd = "warnet plugins simln run-demo" - self.log.info(f"Running: {cmd}") - run_command(cmd) - self.wait_for_predicate(self.found_results_remotely) - self.log.info("Ran SimLn plugin.") - def copy_results(self): self.log.info("Copying results") pod = get_pods_with_label("mission=simln")[0] @@ -59,6 +55,9 @@ def copy_results(self): log_resp = pod_log(pod.metadata.name, "simln") self.log.info(log_resp.data.decode("utf-8")) + partial_func = partial(self.found_results_remotely, pod.metadata.name) + self.wait_for_predicate(partial_func) + download(pod.metadata.name, Path("/working/results"), Path("."), pod.metadata.namespace) self.wait_for_predicate(self.found_results_locally) From 9071720055fb432efd4457121c0077f66db12b87 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 5 Dec 2024 15:29:25 -0600 Subject: [PATCH 10/35] remove "warnet consumes plugin" --- resources/plugins/simln/plugin.yaml | 1 - resources/plugins/simln/simln.py | 30 ++-- src/warnet/constants.py | 2 +- src/warnet/control.py | 6 - src/warnet/main.py | 19 +-- src/warnet/plugins.py | 224 ---------------------------- src/warnet/status.py | 30 ---- test/simln_test.py | 27 +--- 8 files changed, 20 insertions(+), 319 deletions(-) delete mode 100644 resources/plugins/simln/plugin.yaml delete mode 100644 src/warnet/plugins.py diff --git a/resources/plugins/simln/plugin.yaml b/resources/plugins/simln/plugin.yaml deleted file mode 100644 index d4ca94189..000000000 --- a/resources/plugins/simln/plugin.yaml +++ /dev/null @@ -1 +0,0 @@ -enabled: true diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 929386d20..23e537ec6 100755 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -4,14 +4,13 @@ import logging import time from pathlib import Path -from typing import Optional import click from kubernetes.stream import stream # When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" # tag for "lightning" nodes is stored in LIGHTNING_MISSION. -from warnet.constants import LIGHTNING_MISSION, USER_DIR_TAG +from warnet.constants import LIGHTNING_MISSION, PLUGIN_DIR_TAG from warnet.k8s import ( download, get_default_namespace, @@ -20,7 +19,6 @@ wait_for_init, write_file_to_container, ) -from warnet.plugins import get_plugins_directory_or from warnet.process import run_command # To make a "mission" tag for your plugin, declare it using the variable name MISSION. This will @@ -55,15 +53,9 @@ class SimLNError(Exception): @click.pass_context def simln(ctx): """Commands for the SimLN plugin""" - try: - # check if we have set a user directory - ctx.obj.get(USER_DIR_TAG) - except Exception: - # if not, set the great-grandparent of this file as the user dir - ctx.ensure_object(dict) - user_dir = Path(__file__).resolve().parent.parent.parent - ctx.obj[USER_DIR_TAG] = Path(user_dir) - pass + ctx.ensure_object(dict) + plugin_dir = Path(__file__).resolve().parent + ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) # Make sure to register your plugin by adding the group function like so: @@ -74,8 +66,8 @@ def warnet_register_plugin(register_command): # The group function name is then used in decorators to create commands. These commands are # available to users when they access your plugin from the command line in Warnet. @simln.command() -def list_simln_podnames(): - """Get a list of simln pod names""" +def list_pod_names(): + """Get a list of SimLN pod names""" print([pod.metadata.name for pod in get_mission(MISSION)]) @@ -111,14 +103,12 @@ def get_example_activity(): print(json.dumps(_get_example_activity())) -def _launch_activity(activity: list[dict], user_dir: Optional[str] = None) -> str: +def _launch_activity(activity: list[dict], plugin_dir: str) -> str: """Launch a SimLN chart which includes the `activity`""" - plugin_dir = get_plugins_directory_or(user_dir) - timestamp = int(time.time()) name = f"simln-{timestamp}" - command = f"helm upgrade --install {timestamp} {plugin_dir}/simln/charts/simln" + command = f"helm upgrade --install {timestamp} {plugin_dir}/charts/simln" run_command(command) activity_json = _generate_activity_json(activity) @@ -147,8 +137,8 @@ def launch_activity(ctx, activity: str): except json.JSONDecodeError: log.error("Invalid JSON input for activity.") raise click.BadArgumentUsage("Activity must be a valid JSON string.") from None - user_dir = ctx.obj.get(USER_DIR_TAG) - print(_launch_activity(parsed_activity, user_dir)) + plugin_dir = ctx.obj.get(PLUGIN_DIR_TAG) + print(_launch_activity(parsed_activity, plugin_dir)) def _generate_activity_json(activity: list[dict]) -> str: diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 808eed68b..4a7547553 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -42,7 +42,7 @@ DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" # Plugin architecture -USER_DIR_TAG = "user_dir" +PLUGIN_DIR_TAG = "plugin_dir" PLUGINS_TAG = "plugins" ENABLED_TAG = "enabled" PLUGIN_YAML = "plugin.yaml" diff --git a/src/warnet/control.py b/src/warnet/control.py index cda202e90..d26614a48 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -1,5 +1,4 @@ import io -import itertools import json import os import subprocess @@ -42,7 +41,6 @@ wait_for_pod, write_file_to_container, ) -from .plugins import get_plugin_missions, get_plugin_primary_containers from .process import run_command, stream_command console = Console() @@ -386,11 +384,8 @@ def format_pods(pods: list[V1Pod]) -> list[str]: pod_list = [] formatted_commanders = format_pods(get_mission(COMMANDER_MISSION)) formatted_tanks = format_pods(get_mission(TANK_MISSION)) - plugin_pods = [get_mission(mission) for mission in get_plugin_missions()] - formatted_plugins = format_pods(list(itertools.chain.from_iterable(plugin_pods))) pod_list.extend(formatted_commanders) pod_list.extend(formatted_tanks) - pod_list.extend(formatted_plugins) except Exception as e: print(f"Could not fetch any pods in namespace ({namespace}): {e}") @@ -416,7 +411,6 @@ def format_pods(pods: list[V1Pod]) -> list[str]: try: pod = get_pod(pod_name, namespace=namespace) eligible_container_names = [BITCOINCORE_CONTAINER, COMMANDER_CONTAINER] - eligible_container_names.extend(get_plugin_primary_containers()) available_container_names = [container.name for container in pod.spec.containers] container_name = next( ( diff --git a/src/warnet/main.py b/src/warnet/main.py index 18d7b7e46..868147748 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -1,9 +1,5 @@ -from pathlib import Path - import click -from warnet.constants import USER_DIR_TAG - from .admin import admin from .bitcoin import bitcoin from .control import down, logs, run, snapshot, stop @@ -12,25 +8,13 @@ from .graph import create, graph, import_network from .image import image from .ln import ln -from .plugins import load_plugins, load_user_modules, plugins from .project import init, new, setup from .status import status from .users import auth @click.group() -@click.option( - "--user-dir", - type=click.Path(exists=True, file_okay=False), - help="Path to the user's Warnet project directory.", -) -@click.pass_context -def cli(ctx, user_dir: str): - ctx.ensure_object(dict) # initialize ctx object - if user_dir: - ctx.obj[USER_DIR_TAG] = Path(user_dir) - if load_user_modules(ctx.obj.get(USER_DIR_TAG)): - load_plugins() +def cli(): pass @@ -53,7 +37,6 @@ def cli(ctx, user_dir: str): cli.add_command(status) cli.add_command(stop) cli.add_command(create) -cli.add_command(plugins) if __name__ == "__main__": diff --git a/src/warnet/plugins.py b/src/warnet/plugins.py deleted file mode 100644 index da457d413..000000000 --- a/src/warnet/plugins.py +++ /dev/null @@ -1,224 +0,0 @@ -import copy -import importlib.util -import inspect -import os -import sys -import tempfile -from pathlib import Path -from types import ModuleType -from typing import Optional - -import click -import inquirer -import yaml -from inquirer.themes import GreenPassion - -from warnet.constants import ( - CONTAINER_TAG, - ENABLED_TAG, - MISSION_TAG, - PLUGIN_YAML, - PLUGINS_TAG, - USER_DIR_TAG, - WARNET_USER_DIR_ENV_VAR, -) - - -class PluginError(Exception): - pass - - -imported_modules: dict[str, ModuleType] = {} - - -@click.group(name=PLUGINS_TAG) -def plugins(): - """Control plugins""" - pass - - -@plugins.command() -def run(plugin: str): - """Run a file""" - pass - - -@plugins.command() -@click.pass_context -def ls(ctx): - """List all available plugins and whether they are activated""" - plugin_dir = get_plugins_directory_or(ctx.obj.get(USER_DIR_TAG)) - if plugin_dir is None: - direct_user_to_plugin_directory_and_exit() - - for plugin, status in get_plugins_with_status(plugin_dir): - if status: - click.secho(f"{plugin.stem:<20} enabled", fg="green") - else: - click.secho(f"{plugin.stem:<20} disabled", fg="yellow") - - -@plugins.command() -@click.argument("plugin", type=str, default="") -@click.pass_context -def toggle(ctx, plugin: str): - """Toggle a plugin on or off""" - plugin_dir = get_plugins_directory_or(ctx.obj.get(USER_DIR_TAG)) - if plugin_dir is None: - direct_user_to_plugin_directory_and_exit() - - if plugin == "": - plugin_list = get_plugins_with_status(plugin_dir) - formatted_list = [ - f"{str(name.stem):<25} ◦ enabled: {active}" for name, active in plugin_list - ] - - try: - q = [ - inquirer.List( - name=PLUGINS_TAG, - message="Toggle a plugin, or ctrl-c to cancel", - choices=formatted_list, - ) - ] - selected = inquirer.prompt(q, theme=GreenPassion()) - plugin = selected[PLUGINS_TAG].split("◦")[0].strip() - except TypeError: - # user cancels and `selected[plugins_tag] fails with TypeError - sys.exit(0) - - plugin_settings = read_yaml(plugin_dir / Path(plugin) / PLUGIN_YAML) - updated_settings = copy.deepcopy(plugin_settings) - updated_settings[ENABLED_TAG] = not plugin_settings[ENABLED_TAG] - write_yaml(updated_settings, plugin_dir / Path(plugin) / Path(PLUGIN_YAML)) - - -def load_user_modules(path: Optional[Path] = None) -> bool: - was_successful_load = False - - plugin_dir = get_plugins_directory_or(path) - - if not plugin_dir or not plugin_dir.is_dir(): - return was_successful_load - - enabled_plugins = [plugin for plugin, enabled in get_plugins_with_status(plugin_dir) if enabled] - - if not enabled_plugins: - return was_successful_load - - # Temporarily add the directory to sys.path for imports - sys.path.insert(0, str(plugin_dir)) - - for plugin_path in enabled_plugins: - for file in plugin_path.glob("*.py"): - if file.stem not in ("__init__"): - module_name = f"{PLUGINS_TAG}.{file.stem}" - spec = importlib.util.spec_from_file_location(module_name, file) - module = importlib.util.module_from_spec(spec) - imported_modules[module_name] = module - sys.modules[module_name] = module - spec.loader.exec_module(module) - was_successful_load = True - - # Remove the added path from sys.path - sys.path.pop(0) - return was_successful_load - - -def register_command(command): - """Register a command to the CLI.""" - from warnet.main import cli - - register = cli.commands.get(PLUGINS_TAG) - register.add_command(command) - - -def load_plugins(): - for module in imported_modules.values(): - for name, func in inspect.getmembers(module, inspect.isfunction): - if name == "warnet_register_plugin": - func(register_command) - - -def get_plugins_directory_or(path: Optional[Path] = None) -> Optional[Path]: - """Get the plugins directory - user-provided path > environment variable > relative path - """ - if path: - if path.is_dir(): - return path / PLUGINS_TAG - else: - click.secho(f"Not a directory: {path}", fg="red") - - user_dir = os.getenv(WARNET_USER_DIR_ENV_VAR) - - plugin_dir = Path(user_dir) / PLUGINS_TAG if user_dir else Path.cwd() / PLUGINS_TAG - - if plugin_dir and plugin_dir.is_dir(): - return plugin_dir - else: - return None - - -def direct_user_to_plugin_directory_and_exit(): - click.secho("Could not determine the plugin directory location.") - click.secho( - "Solution 1: try runing this command again, but this time from your initialized Warnet directory." - ) - click.secho( - "Solution 2: consider setting environment variable pointing to your Warnet project directory:" - ) - click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") - sys.exit(1) - - -def read_yaml(path: Path) -> dict: - try: - with open(path) as file: - return yaml.safe_load(file) - except FileNotFoundError as e: - raise PluginError(f"YAML file {path} not found.") from e - except yaml.YAMLError as e: - raise PluginError(f"Error parsing yaml: {e}") from e - - -def write_yaml(yaml_dict: dict, path: Path) -> None: - dir_name = os.path.dirname(path) - try: - with tempfile.NamedTemporaryFile("w", dir=dir_name, delete=False) as temp_file: - yaml.safe_dump(yaml_dict, temp_file) - os.replace(temp_file.name, path) - except Exception as e: - os.remove(temp_file.name) - raise PluginError(f"Error writing kubeconfig: {path}") from e - - -def check_if_plugin_enabled(path: Path) -> bool: - enabled = None - try: - plugin_dict = read_yaml(path / Path(PLUGIN_YAML)) - enabled = plugin_dict.get(ENABLED_TAG) - except PluginError as e: - click.secho(e) - - return bool(enabled) - - -def get_plugins_with_status(plugin_dir: Optional[Path] = None) -> list[tuple[Path, bool]]: - if not plugin_dir: - plugin_dir = get_plugins_directory_or() - candidates = [ - Path(os.path.join(plugin_dir, name)) - for name in os.listdir(plugin_dir) - if os.path.isdir(os.path.join(plugin_dir, name)) - ] - plugins = [plugin_dir for plugin_dir in candidates if any(plugin_dir.glob(PLUGIN_YAML))] - return [(plugin, check_if_plugin_enabled(plugin)) for plugin in plugins] - - -def get_plugin_missions() -> list[str]: - return [getattr(module, MISSION_TAG.upper(), None) for module in imported_modules.values()] - - -def get_plugin_primary_containers() -> list[str]: - return [getattr(module, CONTAINER_TAG.upper(), None) for module in imported_modules.values()] diff --git a/src/warnet/status.py b/src/warnet/status.py index 3c70cb89c..c94f014cc 100644 --- a/src/warnet/status.py +++ b/src/warnet/status.py @@ -1,4 +1,3 @@ -import itertools import sys import click @@ -12,7 +11,6 @@ from .constants import COMMANDER_MISSION, TANK_MISSION from .k8s import get_mission from .network import _connected -from .plugins import get_plugin_missions @click.command() @@ -23,7 +21,6 @@ def status(): try: tanks = _get_tank_status() scenarios = _get_deployed_scenarios() - plugins = _get_plugin_status() except ConfigException as e: print(e) print( @@ -69,20 +66,6 @@ def status(): else: table.add_row("Scenario", "No active scenarios", "") - # Add a separator if there are plugins - if plugins: - table.add_row("", "", "") - - # Add plugins to the table - active_plugins = 0 - if plugins: - for plugin in plugins: - table.add_row("Plugin", plugin["name"], plugin["status"], plugin["namespace"]) - if plugin["status"] == "running" or plugin["status"] == "pending": - active_plugins += 1 - else: - table.add_row("Plugin", "No active plugins", "") - # Create a panel to wrap the table panel = Panel( table, @@ -99,7 +82,6 @@ def status(): summary = Text() summary.append(f"\nTotal Tanks: {len(tanks)}", style="bold cyan") summary.append(f" | Active Scenarios: {active}", style="bold green") - summary.append(f" | Active Plugins: {active_plugins}", style="bold green") console.print(summary) _connected(end="\r") @@ -116,18 +98,6 @@ def _get_tank_status(): ] -def _get_plugin_status(): - plugin_pods = [get_mission(mission) for mission in get_plugin_missions()] - return [ - { - "name": pod.metadata.name, - "status": pod.status.phase.lower(), - "namespace": pod.metadata.namespace, - } - for pod in list(itertools.chain.from_iterable(plugin_pods)) # flatten - ] - - def _get_deployed_scenarios(): commanders = get_mission(COMMANDER_MISSION) return [ diff --git a/test/simln_test.py b/test/simln_test.py index e3d81edf7..7c1680a49 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -22,6 +22,7 @@ def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" + self.simln_exec = "plugins/simln/simln.py" def run_test(self): try: @@ -30,12 +31,12 @@ def run_test(self): self.import_network() self.setup_network() - self.run_ln_init_scenario() + self.test_channel_policies() + self.test_payments() self.run_simln() self.copy_results() self.run_activity() - self.run_activity_with_user_dir() finally: self.cleanup() @@ -62,28 +63,16 @@ def copy_results(self): self.wait_for_predicate(self.found_results_locally) def run_activity(self): - cmd = "warnet plugins simln get-example-activity" + cmd = f"{self.simln_exec} get-example-activity" self.log.info(f"Activity: {cmd}") activity_result = run_command(cmd) activity = json.loads(activity_result) - pod_result = run_command(f"warnet plugins simln launch-activity '{json.dumps(activity)}'") + pod_result = run_command(f"{self.simln_exec} launch-activity '{json.dumps(activity)}'") self.log.info(f"launched activity: {pod_result}") partial_func = partial(self.found_results_remotely, pod_result.strip()) self.wait_for_predicate(partial_func) self.log.info("Successfully ran activity") - def run_activity_with_user_dir(self): - cmd = "mkdir temp; cd temp; warnet --user-dir ../ plugins simln get-example-activity; cd ../; rm -rf temp" - self.log.info(f"Activity: {cmd}") - activity_result = run_command(cmd) - activity = json.loads(activity_result) - pod_result = run_command(f"warnet plugins simln launch-activity '{json.dumps(activity)}'") - self.log.info(f"launched activity: {pod_result}") - partial_func = partial(self.found_results_remotely, pod_result.strip()) - self.wait_for_predicate(partial_func) - run_command("cd ../") - self.log.info("Successfully ran activity using --user-dir") - def wait_for_gossip_sync(self, expected: int): self.log.info(f"Waiting for sync (expecting {expected})...") current = 0 @@ -100,14 +89,14 @@ def wait_for_gossip_sync(self, expected: int): def found_results_remotely(self, pod: Optional[str] = None) -> bool: if pod is None: - pod_names_literal = run_command("warnet plugins simln list-simln-podnames") + pod_names_literal = run_command(f"{self.simln_exec} list-pod-names") pod_names = ast.literal_eval(pod_names_literal) pod = pod_names[0] self.log.info(f"Checking for results file in {pod}") - results_file = run_command(f"warnet plugins simln sh {pod} ls /working/results").strip() + results_file = run_command(f"{self.simln_exec} sh {pod} ls /working/results").strip() self.log.info(f"Results file: {results_file}") results = run_command( - f"warnet plugins simln sh {pod} cat /working/results/{results_file}" + f"{self.simln_exec} sh {pod} cat /working/results/{results_file}" ).strip() self.log.info(results) return results.find("Success") > 0 From 3f125d626c5b07937d3d0d359ed1fff898c61d22 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 5 Dec 2024 16:03:09 -0600 Subject: [PATCH 11/35] clear out constants cruft --- .../plugins/simln/charts/simln/templates/NOTES.txt | 2 +- resources/plugins/simln/charts/simln/values.yaml | 1 - src/warnet/constants.py | 12 +----------- src/warnet/project.py | 6 ------ 4 files changed, 2 insertions(+), 19 deletions(-) diff --git a/resources/plugins/simln/charts/simln/templates/NOTES.txt b/resources/plugins/simln/charts/simln/templates/NOTES.txt index 2d8319bde..74486845f 100644 --- a/resources/plugins/simln/charts/simln/templates/NOTES.txt +++ b/resources/plugins/simln/charts/simln/templates/NOTES.txt @@ -1 +1 @@ -Thank you for installing simln. +Thank you for installing SimLN. diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml index 838f7a542..ecbaba1e8 100644 --- a/resources/plugins/simln/charts/simln/values.yaml +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -11,4 +11,3 @@ configmapVolume: name: configmap-volume mountPath: /configmap -defaultDataDir: /app/data diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 4a7547553..986c70627 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -18,13 +18,10 @@ KUBE_INTERNAL_NAMESPACES = ["kube-node-lease", "kube-public", "kube-system", "kubernetes-dashboard"] HELM_COMMAND = "helm upgrade --install" -MISSION_TAG = "mission" TANK_MISSION = "tank" COMMANDER_MISSION = "commander" -PLUGIN_MISSION = "plugin" LIGHTNING_MISSION = "lightning" -CONTAINER_TAG = "container" BITCOINCORE_CONTAINER = "bitcoincore" COMMANDER_CONTAINER = "commander" @@ -36,19 +33,12 @@ SCENARIOS_DIR = RESOURCES_DIR.joinpath("scenarios") CHARTS_DIR = RESOURCES_DIR.joinpath("charts") MANIFESTS_DIR = RESOURCES_DIR.joinpath("manifests") +PLUGINS_DIR = RESOURCES_DIR.joinpath("plugins") NETWORK_FILE = "network.yaml" DEFAULTS_FILE = "node-defaults.yaml" NAMESPACES_FILE = "namespaces.yaml" DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" -# Plugin architecture -PLUGIN_DIR_TAG = "plugin_dir" -PLUGINS_TAG = "plugins" -ENABLED_TAG = "enabled" -PLUGIN_YAML = "plugin.yaml" -PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_TAG) -WARNET_USER_DIR_ENV_VAR = "WARNET_USER_DIR" - # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) LND_CHART_LOCATION = str(CHARTS_DIR.joinpath("lnd")) diff --git a/src/warnet/project.py b/src/warnet/project.py index a031dc768..c4122d916 100644 --- a/src/warnet/project.py +++ b/src/warnet/project.py @@ -24,7 +24,6 @@ KUBECTL_BLESSED_NAME_AND_CHECKSUMS, KUBECTL_BLESSED_VERSION, KUBECTL_DOWNLOAD_URL_STUB, - WARNET_USER_DIR_ENV_VAR, ) from .graph import inquirer_create_network from .network import copy_network_defaults, copy_plugins_defaults, copy_scenario_defaults @@ -440,11 +439,6 @@ def new_internal(directory: Path, from_init=False): click.echo("\nWhen you're ready, run the following command to deploy this network:") click.echo(f" warnet deploy {custom_network_path}") - click.secho( - "Consider setting an environment variable to make it easier to access your user directory:" - ) - click.secho(f"export {WARNET_USER_DIR_ENV_VAR}={directory}", fg="yellow") - @click.command() def init(): From b904879d0a0b74bcd08fa22fb08f4e4931fbc51f Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 5 Dec 2024 16:03:19 -0600 Subject: [PATCH 12/35] allow simln to declare its constants --- resources/plugins/simln/simln.py | 20 +++++++++++++++++--- test/simln_test.py | 11 +++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 23e537ec6..e863e2df0 100755 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -10,7 +10,7 @@ # When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" # tag for "lightning" nodes is stored in LIGHTNING_MISSION. -from warnet.constants import LIGHTNING_MISSION, PLUGIN_DIR_TAG +from warnet.constants import LIGHTNING_MISSION from warnet.k8s import ( download, get_default_namespace, @@ -30,7 +30,9 @@ # explicit here using the variable name CONTAINER which Warnet uses internally in its log and status # systems. # Again, this should match the container name provided in the associated helm file. -CONTAINER = MISSION +PRIMARY_CONTAINER = MISSION + +PLUGIN_DIR_TAG = "plugin_dir" class SimLNError(Exception): @@ -173,7 +175,7 @@ def _sh(pod, method: str, params: tuple[str, ...]) -> str: sclient.connect_get_namespaced_pod_exec, pod, namespace, - container=CONTAINER, + container=PRIMARY_CONTAINER, command=cmd, stderr=True, stdin=False, @@ -205,5 +207,17 @@ def sh(pod: str, method: str, params: tuple[str, ...]): print(_sh(pod, method, params)) +@simln.command() +def mission(): + """Return the SimLN mission name""" + click.secho(MISSION) + + +@simln.command() +def primary_container(): + """Return the SimLN primary container name.""" + click.secho(PRIMARY_CONTAINER) + + if __name__ == "__main__": simln() diff --git a/test/simln_test.py b/test/simln_test.py index 7c1680a49..48b9e4434 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -11,11 +11,10 @@ from ln_test import LNTest from test_base import TestBase -from warnet.k8s import download, get_pods_with_label, pod_log, wait_for_pod +from warnet.constants import LIGHTNING_MISSION +from warnet.k8s import download, get_mission, pod_log, wait_for_pod from warnet.process import run_command -lightning_selector = "mission=lightning" - class SimLNTest(LNTest, TestBase): def __init__(self): @@ -49,11 +48,11 @@ def init_directory(self): def copy_results(self): self.log.info("Copying results") - pod = get_pods_with_label("mission=simln")[0] + pod = get_mission(f"{self.simln_exec} mission")[0] self.wait_for_gossip_sync(2) wait_for_pod(pod.metadata.name, 60) - log_resp = pod_log(pod.metadata.name, "simln") + log_resp = pod_log(pod.metadata.name, f"{self.simln_exec} primary-container") self.log.info(log_resp.data.decode("utf-8")) partial_func = partial(self.found_results_remotely, pod.metadata.name) @@ -78,7 +77,7 @@ def wait_for_gossip_sync(self, expected: int): current = 0 while current < expected: current = 0 - pods = get_pods_with_label(lightning_selector) + pods = get_mission(LIGHTNING_MISSION) for v1_pod in pods: node = v1_pod.metadata.name chs = json.loads(run_command(f"warnet ln rpc {node} describegraph"))["edges"] From 1789a53c7eee3527c02047acee0e055657de2b09 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 6 Dec 2024 11:10:35 -0600 Subject: [PATCH 13/35] de-dupe deploy code --- src/warnet/deploy.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 455c00cac..ba39a4841 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -65,12 +65,7 @@ def deploy(directory, debug, namespace, to_all_users, unknown_args): if unknown_args: raise click.BadParameter(f"Unknown args: {unknown_args}{HINT}") - if to_all_users: - namespaces = get_namespaces_by_type(WARGAMES_NAMESPACE_PREFIX) - for namespace in namespaces: - _deploy(directory, debug, namespace.metadata.name, False) - else: - _deploy(directory, debug, namespace, to_all_users) + _deploy(directory, debug, namespace, to_all_users) def _deploy(directory, debug, namespace, to_all_users): @@ -81,7 +76,7 @@ def _deploy(directory, debug, namespace, to_all_users): namespaces = get_namespaces_by_type(WARGAMES_NAMESPACE_PREFIX) processes = [] for namespace in namespaces: - p = Process(target=deploy, args=(directory, debug, namespace.metadata.name, False)) + p = Process(target=_deploy, args=(directory, debug, namespace.metadata.name, False)) p.start() processes.append(p) for p in processes: From 77ac67ceeafb35cf9387c3d5fbb873e80462bfd6 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 6 Dec 2024 11:11:16 -0600 Subject: [PATCH 14/35] deploy plugins from network.yaml --- resources/plugins/simln/simln.py | 30 ++++++++--------- src/warnet/deploy.py | 20 ++++++++++-- test/data/ln/network.yaml | 5 ++- test/ln_test.py | 41 ++++++++++++++++++++---- test/simln_test.py | 55 ++++++++++++-------------------- 5 files changed, 91 insertions(+), 60 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index e863e2df0..b578f5cd5 100755 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -105,6 +105,21 @@ def get_example_activity(): print(json.dumps(_get_example_activity())) +# Take note of how click expects us to explicitly declare command line arguments. +@simln.command() +@click.argument("activity", type=str) +@click.pass_context +def launch_activity(ctx, activity: str): + """Deploys a SimLN Activity which is a JSON list of objects""" + try: + parsed_activity = json.loads(activity) + except json.JSONDecodeError: + log.error("Invalid JSON input for activity.") + raise click.BadArgumentUsage("Activity must be a valid JSON string.") from None + plugin_dir = ctx.obj.get(PLUGIN_DIR_TAG) + print(_launch_activity(parsed_activity, plugin_dir)) + + def _launch_activity(activity: list[dict], plugin_dir: str) -> str: """Launch a SimLN chart which includes the `activity`""" timestamp = int(time.time()) @@ -128,21 +143,6 @@ def _launch_activity(activity: list[dict], plugin_dir: str) -> str: raise SimLNError(f"Could not write sim.json to the init container: {name}") -# Take note of how click expects us to explicitly declare command line arguments. -@simln.command() -@click.argument("activity", type=str) -@click.pass_context -def launch_activity(ctx, activity: str): - """Deploys a SimLN Activity which is a JSON list of objects""" - try: - parsed_activity = json.loads(activity) - except json.JSONDecodeError: - log.error("Invalid JSON input for activity.") - raise click.BadArgumentUsage("Activity must be a valid JSON string.") from None - plugin_dir = ctx.obj.get(PLUGIN_DIR_TAG) - print(_launch_activity(parsed_activity, plugin_dir)) - - def _generate_activity_json(activity: list[dict]) -> str: nodes = [] diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index ba39a4841..31f88b9a1 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -34,7 +34,7 @@ wait_for_ingress_controller, wait_for_pod_ready, ) -from .process import stream_command +from .process import run_command, stream_command HINT = "\nAre you trying to run a scenario? See `warnet run --help`" @@ -115,6 +115,8 @@ def _deploy(directory, debug, namespace, to_all_users): for p in processes: p.join() + run_plugins(directory) + elif (directory / NAMESPACES_FILE).exists(): deploy_namespaces(directory) else: @@ -123,6 +125,19 @@ def _deploy(directory, debug, namespace, to_all_users): ) +def run_plugins(directory): + network_file_path = directory / NETWORK_FILE + + with network_file_path.open() as f: + network_file = yaml.safe_load(f) + + plugins = network_file.get("plugins") or [] + for plugin_cmd in plugins: + fully_qualified_cmd = f"{network_file_path.parent}/{plugin_cmd}" # relative to network.yaml + print(fully_qualified_cmd) + print(run_command(fully_qualified_cmd)) + + def check_logging_required(directory: Path): # check if node-defaults has logging or metrics enabled default_file_path = directory / DEFAULTS_FILE @@ -137,7 +152,8 @@ def check_logging_required(directory: Path): network_file_path = directory / NETWORK_FILE with network_file_path.open() as f: network_file = yaml.safe_load(f) - nodes = network_file.get("nodes", []) + + nodes = network_file.get("nodes") or [] for node in nodes: if node.get("collectLogs", False): return True diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index b125533c8..e1e77ac9b 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -51,4 +51,7 @@ nodes: addnode: - tank-0000 ln: - lnd: true \ No newline at end of file + lnd: true + +plugins: + - "../../../resources/plugins/simln/simln.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" diff --git a/test/ln_test.py b/test/ln_test.py index 8bab820c8..03bb8eab9 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 - +import ast import json import os from pathlib import Path +from typing import Optional from test_base import TestBase +from warnet.k8s import wait_for_pod from warnet.process import run_command, stream_command @@ -16,6 +18,7 @@ def __init__(self): self.imported_network_dir = self.tmpdir / "imported_network" self.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" + self.simln_exec = Path("simln/simln.py") def run_test(self): try: @@ -84,12 +87,36 @@ def get_and_pay(src, tgt): get_and_pay(4, 6) def run_simln(self): - self.log.info("Running activity") - activity_cmd = f"{self.plugins_dir}/simln/simln.py get-example-activity" - activity = run_command(activity_cmd).strip() - self.log.info(f"Activity: {activity}") - command = f"{self.plugins_dir}/simln/simln.py launch-activity '{activity}'" - self.log.info(run_command(command)) + self.log.info("Running SimLN...") + activity_cmd = f"{self.plugins_dir}/{self.simln_exec} get-example-activity" + activity = run_command(activity_cmd) + launch_cmd = f"{self.plugins_dir}/{self.simln_exec} launch-activity '{activity}'" + pod = run_command(launch_cmd).strip() + wait_for_pod(pod) + self.log.info("Checking SimLN...") + self.wait_for_predicate(self.found_results_remotely) + self.log.info("SimLN was successful.") + + def found_results_remotely(self, pod: Optional[str] = None) -> bool: + if pod is None: + pod = self.get_first_simln_pod() + self.log.info(f"Checking for results file in {pod}") + results_file = run_command( + f"{self.plugins_dir}/{self.simln_exec} sh {pod} ls /working/results" + ).strip() + self.log.info(f"Results file: {results_file}") + results = run_command( + f"{self.plugins_dir}/{self.simln_exec} sh {pod} cat /working/results/{results_file}" + ).strip() + self.log.info(results) + return results.find("Success") > 0 + + def get_first_simln_pod(self): + command = f"{self.plugins_dir}/{self.simln_exec} list-pod-names" + pod_names_literal = run_command(command) + self.log.info(f"{command}: {pod_names_literal}") + pod_names = ast.literal_eval(pod_names_literal) + return pod_names[0] if __name__ == "__main__": diff --git a/test/simln_test.py b/test/simln_test.py index 48b9e4434..9fa63b02b 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -8,15 +8,14 @@ from typing import Optional import pexpect -from ln_test import LNTest from test_base import TestBase from warnet.constants import LIGHTNING_MISSION -from warnet.k8s import download, get_mission, pod_log, wait_for_pod +from warnet.k8s import download, get_mission, wait_for_pod from warnet.process import run_command -class SimLNTest(LNTest, TestBase): +class SimLNTest(TestBase): def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" @@ -27,15 +26,8 @@ def run_test(self): try: os.chdir(self.tmpdir) self.init_directory() - - self.import_network() - self.setup_network() - self.test_channel_policies() - self.test_payments() - self.run_simln() - + self.deploy_with_plugin() self.copy_results() - self.run_activity() finally: self.cleanup() @@ -46,32 +38,20 @@ def init_directory(self): self.sut.sendline("n") self.sut.close() - def copy_results(self): - self.log.info("Copying results") - pod = get_mission(f"{self.simln_exec} mission")[0] - self.wait_for_gossip_sync(2) - wait_for_pod(pod.metadata.name, 60) - - log_resp = pod_log(pod.metadata.name, f"{self.simln_exec} primary-container") - self.log.info(log_resp.data.decode("utf-8")) + def deploy_with_plugin(self): + self.log.info("Deploy the ln network with a SimLN plugin") + results = self.warnet(f"deploy {self.network_dir}") + self.log.info(results) + wait_for_pod(self.get_first_simln_pod()) - partial_func = partial(self.found_results_remotely, pod.metadata.name) + def copy_results(self): + pod = self.get_first_simln_pod() + partial_func = partial(self.found_results_remotely, pod) self.wait_for_predicate(partial_func) - download(pod.metadata.name, Path("/working/results"), Path("."), pod.metadata.namespace) + download(pod, Path("/working/results"), Path(".")) self.wait_for_predicate(self.found_results_locally) - def run_activity(self): - cmd = f"{self.simln_exec} get-example-activity" - self.log.info(f"Activity: {cmd}") - activity_result = run_command(cmd) - activity = json.loads(activity_result) - pod_result = run_command(f"{self.simln_exec} launch-activity '{json.dumps(activity)}'") - self.log.info(f"launched activity: {pod_result}") - partial_func = partial(self.found_results_remotely, pod_result.strip()) - self.wait_for_predicate(partial_func) - self.log.info("Successfully ran activity") - def wait_for_gossip_sync(self, expected: int): self.log.info(f"Waiting for sync (expecting {expected})...") current = 0 @@ -88,9 +68,7 @@ def wait_for_gossip_sync(self, expected: int): def found_results_remotely(self, pod: Optional[str] = None) -> bool: if pod is None: - pod_names_literal = run_command(f"{self.simln_exec} list-pod-names") - pod_names = ast.literal_eval(pod_names_literal) - pod = pod_names[0] + pod = self.get_first_simln_pod() self.log.info(f"Checking for results file in {pod}") results_file = run_command(f"{self.simln_exec} sh {pod} ls /working/results").strip() self.log.info(f"Results file: {results_file}") @@ -100,6 +78,13 @@ def found_results_remotely(self, pod: Optional[str] = None) -> bool: self.log.info(results) return results.find("Success") > 0 + def get_first_simln_pod(self): + command = f"{self.simln_exec} list-pod-names" + pod_names_literal = run_command(command) + self.log.info(f"{command}: {pod_names_literal}") + pod_names = ast.literal_eval(pod_names_literal) + return pod_names[0] + def found_results_locally(self) -> bool: directory = "results" self.log.info(f"Searching {directory}") From fcd871da15d48108b1b79323aa990121e9df32cd Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 6 Dec 2024 15:44:58 -0600 Subject: [PATCH 15/35] remove contract reporting --- resources/plugins/simln/simln.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index b578f5cd5..8886d35c9 100755 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -207,17 +207,5 @@ def sh(pod: str, method: str, params: tuple[str, ...]): print(_sh(pod, method, params)) -@simln.command() -def mission(): - """Return the SimLN mission name""" - click.secho(MISSION) - - -@simln.command() -def primary_container(): - """Return the SimLN primary container name.""" - click.secho(PRIMARY_CONTAINER) - - if __name__ == "__main__": simln() From 7a4ff3c6b01f961e0062db580cf281777cc0a57a Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 9 Dec 2024 12:34:00 -0600 Subject: [PATCH 16/35] add/modify documentation; bump SimLN version --- docs/plugins.md | 25 ++++ resources/plugins/simln/README.md | 110 ++++++++++++++++++ .../plugins/simln/charts/simln/values.yaml | 2 +- .../plugins/simln/{simln.py => plugin.py} | 28 ++--- test/ln_test.py | 2 +- test/simln_test.py | 21 +--- 6 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 docs/plugins.md create mode 100644 resources/plugins/simln/README.md rename resources/plugins/simln/{simln.py => plugin.py} (84%) diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..94b1b45ae --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,25 @@ +# Plugins + +Plugins allow users to extend Warnet. Plugin authors can import commands from Warnet and plugin users can run plugin commands from the command line or on each invocation of `warnet deploy`. + +## Activating plugins from 'network.yaml' + +You can activate a plugin command by placing it in the `plugin` section at the bottom of each `network.yaml` file like so: + +````yaml +nodes: + <> + +plugins: + - path/to/plugin/file/relative/to/the/network/dot/yaml/file/plugin.py +```` + +Warnet will execute these plugin commands after each invocation of `warnet deploy`. + +## Example: SimLN + +To get started with an example plugin, review the `README` of the `simln` plugin found in any initialized Warnet directory: + +1. `warnet init` +2. `cd plugins/simln/` + diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md new file mode 100644 index 000000000..02740d8e0 --- /dev/null +++ b/resources/plugins/simln/README.md @@ -0,0 +1,110 @@ +# SimLN Plugin + +## SimLN +SimLN helps you generate lightning payment activity. + +* Website: https://simln.dev/ +* Github: https://github.com/bitcoin-dev-project/sim-ln + +## Usage +SimLN uses "activity" definitions to create payment activity between lightning nodes. These definitions are in JSON format. + +SimLN also requires access details for each node; however, the SimLN plugin will automatically generate these access details for each LND node. The access details look like this: + +```` JSON +{ + "id": , + "address": https://, + "macaroon": , + "cert": +} +```` + +Since SimLN already has access to those LND connection details, it means you can focus on the "activity" definitions. + +### Launch activity definitions from the command line +The SimLN plugin takes "activity" definitions like so: + +`./simln/plugin.py launch-activiy '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'"''` + +### Launch activity definitions from within `network.yaml` +When you initialize a new Warnet network, Warnet will create a new `network.yaml` file. If your `network.yaml` file includes lightning nodes, then you can use SimLN to produce activity between those nodes like this: + +
+network.yaml + +````yaml +nodes: + - name: tank-0000 + addnode: + - tank-0001 + ln: + lnd: true + + - name: tank-0001 + addnode: + - tank-0002 + ln: + lnd: true + + - name: tank-0002 + addnode: + - tank-0000 + ln: + lnd: true + + - name: tank-0003 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + config: | + bitcoin.timelockdelta=33 + channels: + - id: + block: 300 + index: 1 + target: tank-0004-ln + capacity: 100000 + push_amt: 50000 + + - name: tank-0004 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + channels: + - id: + block: 300 + index: 2 + target: tank-0005-ln + capacity: 50000 + push_amt: 25000 + + - name: tank-0005 + addnode: + - tank-0000 + ln: + lnd: true + +plugins: + # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `simln.py` file and `network.yaml` file may differ than what is shown below. + - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" +```` + +
+ +## Generating your own SimLn image +The SimLN plugin fetches a SimLN docker image from dockerhub. You can generate your own docker image if you choose: + +1. Clone SimLN: `git clone git@github.com:bitcoin-dev-project/sim-ln.git` +2. Follow the instructions to build a docker image as detailed int the SimLn repository. +3. Tag the resulting docker image: `docker tag IMAGEID YOURUSERNAME/sim-ln:VERSION` +4. Push the tagged image to you dockerhub account. +5Modify the `values.yaml` file in the plugin's chart to reflect your username and version number: +```YAML + repository: "YOURUSERNAME/sim-ln" + tag: "VERSION" +``` diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml index ecbaba1e8..4ffc40961 100644 --- a/resources/plugins/simln/charts/simln/values.yaml +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -1,7 +1,7 @@ name: "simln" image: repository: "mplsgrant/sim-ln" - tag: "d8c165d" + tag: "4d33f24" pullPolicy: IfNotPresent workingVolume: diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/plugin.py similarity index 84% rename from resources/plugins/simln/simln.py rename to resources/plugins/simln/plugin.py index 8886d35c9..71f7df31e 100755 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/plugin.py @@ -21,15 +21,11 @@ ) from warnet.process import run_command +# Tt is common for Warnet objects to have a "mission" tag to query them in the cluster. # To make a "mission" tag for your plugin, declare it using the variable name MISSION. This will # be read by the warnet log system and status system. -# This should match the pod's "mission" value in this plugin's associated helm file. +# This must match the pod's "mission" value in the plugin's associated helm file. MISSION = "simln" - -# Each pod we deploy should have a primary container. We make the name of that primary container -# explicit here using the variable name CONTAINER which Warnet uses internally in its log and status -# systems. -# Again, this should match the container name provided in the associated helm file. PRIMARY_CONTAINER = MISSION PLUGIN_DIR_TAG = "plugin_dir" @@ -50,7 +46,7 @@ class SimLNError(Exception): # Warnet uses a python package called "click" to manage terminal interactions with the user. # Each plugin must declare a click "group" by decorating a function named after the plugin. -# This makes your plugin available in the plugin section of Warnet. +# Using click makes it easy for users to interact with your plugin. @click.group() @click.pass_context def simln(ctx): @@ -60,13 +56,8 @@ def simln(ctx): ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) -# Make sure to register your plugin by adding the group function like so: -def warnet_register_plugin(register_command): - register_command(simln) # <-- We added the group function here. - - -# The group function name is then used in decorators to create commands. These commands are -# available to users when they access your plugin from the command line in Warnet. +# The group name is then used in decorators to create commands. These commands are +# available to users when they access your plugin from the command line. @simln.command() def list_pod_names(): """Get a list of SimLN pod names""" @@ -81,11 +72,10 @@ def download_results(pod_name: str): print(f"Downloaded results to: {dest}") -# When we want to use a command inside our plugin and also provide that command to the user, we like -# to create a private function whose name starts with an underscore. We also make a public function -# with the same name except that we leave off the underscore, decorate it with the command -# decorator, and also provide an instructive doc string which Warnet will display in the help -# section of the command line program. +# When we want to use a command inside our plugin and also provide that command to the user, it +# helps to create a private function whose name starts with an underscore. We also make a public +# function with the same name except that we leave off the underscore, decorate it with the command +# decorator, and also provide an instructive doc string for the user. def _get_example_activity() -> list[dict]: pods = get_mission(LIGHTNING_MISSION) try: diff --git a/test/ln_test.py b/test/ln_test.py index 03bb8eab9..ee27b6256 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -18,7 +18,7 @@ def __init__(self): self.imported_network_dir = self.tmpdir / "imported_network" self.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" - self.simln_exec = Path("simln/simln.py") + self.simln_exec = Path("simln/plugin.py") def run_test(self): try: diff --git a/test/simln_test.py b/test/simln_test.py index 9fa63b02b..6d0ec06f6 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -1,17 +1,14 @@ #!/usr/bin/env python3 import ast -import json import os from functools import partial from pathlib import Path -from time import sleep from typing import Optional import pexpect from test_base import TestBase -from warnet.constants import LIGHTNING_MISSION -from warnet.k8s import download, get_mission, wait_for_pod +from warnet.k8s import download, wait_for_pod from warnet.process import run_command @@ -20,7 +17,7 @@ def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" - self.simln_exec = "plugins/simln/simln.py" + self.simln_exec = "plugins/simln/plugin.py" def run_test(self): try: @@ -52,20 +49,6 @@ def copy_results(self): download(pod, Path("/working/results"), Path(".")) self.wait_for_predicate(self.found_results_locally) - def wait_for_gossip_sync(self, expected: int): - self.log.info(f"Waiting for sync (expecting {expected})...") - current = 0 - while current < expected: - current = 0 - pods = get_mission(LIGHTNING_MISSION) - for v1_pod in pods: - node = v1_pod.metadata.name - chs = json.loads(run_command(f"warnet ln rpc {node} describegraph"))["edges"] - self.log.info(f"{node}: {len(chs)} channels") - current += len(chs) - sleep(1) - self.log.info("Synced") - def found_results_remotely(self, pod: Optional[str] = None) -> bool: if pod is None: pod = self.get_first_simln_pod() From f042913fc5f6826d43fc9d2959d302dcd0872007 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 9 Dec 2024 12:41:39 -0600 Subject: [PATCH 17/35] fixups --- README.md | 1 + resources/plugins/simln/README.md | 2 +- test/data/ln/network.yaml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 51199990e..d2301ad96 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Monitor and analyze the emergent behaviors of Bitcoin networks. - [Installation](/docs/install.md) - [CLI Commands](/docs/warnet.md) - [Network configuration with yaml files](/docs/config.md) +- [Plugins](/docs/plugins.md) - [Scenarios](/docs/scenarios.md) - [Monitoring](/docs/logging_monitoring.md) - [Snapshots](/docs/snapshots.md) diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index 02740d8e0..0905aaa68 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -90,7 +90,7 @@ nodes: lnd: true plugins: - # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `simln.py` file and `network.yaml` file may differ than what is shown below. + # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `plugin.py` file and `network.yaml` file may differ than what is shown below. - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" ```` diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index e1e77ac9b..af0b8c9a0 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -54,4 +54,4 @@ nodes: lnd: true plugins: - - "../../../resources/plugins/simln/simln.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" From e23a728c6fa6ed17608df0111b749286c680f8e5 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 10 Dec 2024 13:30:44 -0600 Subject: [PATCH 18/35] add pre/post deploy options --- resources/plugins/simln/README.md | 18 ++++++++++++++++-- resources/plugins/simln/plugin.py | 2 +- src/warnet/constants.py | 7 +++++++ src/warnet/deploy.py | 20 +++++++++++++------- test/data/ln/network.yaml | 5 ++++- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index 0905aaa68..e7bb432d1 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -90,12 +90,26 @@ nodes: lnd: true plugins: - # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `plugin.py` file and `network.yaml` file may differ than what is shown below. - - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + postDeploy: + # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `plugin.py` file and `network.yaml` file may differ than what is shown below. + - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" ```` +### preDeploy and postDeploy +When using `warnet deploy `, Warnet will look for a `network.yaml` in the network folder, and it will deploy the nodes listed in the `nodes` section of the file. We can choose to execute our plugin functions before Warnet deploys the nodes by including those functions in the `preDeploy` section of the `network.yaml` file. We can also choose to run plugin commands after Warnet deploys the nodes by including the function in the `postDeploy` section. + +````yaml +plugins: + preDeploy: + - path/to/plugin.py setup_function + postDeploy: + - path/to/plugin.py run + - path/to/other/plugin.py run +```` + + ## Generating your own SimLn image The SimLN plugin fetches a SimLN docker image from dockerhub. You can generate your own docker image if you choose: diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 71f7df31e..a9e580d92 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -45,7 +45,7 @@ class SimLNError(Exception): # Warnet uses a python package called "click" to manage terminal interactions with the user. -# Each plugin must declare a click "group" by decorating a function named after the plugin. +# To use click, we must declare a click "group" by decorating a function named after the plugin. # Using click makes it easy for users to interact with your plugin. @click.group() @click.pass_context diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 986c70627..9c41c8569 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -1,4 +1,5 @@ import os +from enum import Enum from importlib.resources import files from pathlib import Path @@ -25,6 +26,12 @@ BITCOINCORE_CONTAINER = "bitcoincore" COMMANDER_CONTAINER = "commander" + +class HookValue(Enum): + PRE_DEPLOY = "preDeploy" + POST_DEPLOY = "postDeploy" + + # Directories and files for non-python assets, e.g., helm charts, example scenarios, default configs SRC_DIR = files("warnet") RESOURCES_DIR = files("resources") diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 31f88b9a1..addf40181 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -24,6 +24,7 @@ NETWORK_FILE, SCENARIOS_DIR, WARGAMES_NAMESPACE_PREFIX, + HookValue, ) from .control import _run from .k8s import ( @@ -72,6 +73,8 @@ def _deploy(directory, debug, namespace, to_all_users): """Deploy a warnet with topology loaded from """ directory = Path(directory) + run_plugins(directory, HookValue.PRE_DEPLOY) + if to_all_users: namespaces = get_namespaces_by_type(WARGAMES_NAMESPACE_PREFIX) processes = [] @@ -115,7 +118,7 @@ def _deploy(directory, debug, namespace, to_all_users): for p in processes: p.join() - run_plugins(directory) + run_plugins(directory, HookValue.POST_DEPLOY) elif (directory / NAMESPACES_FILE).exists(): deploy_namespaces(directory) @@ -125,17 +128,20 @@ def _deploy(directory, debug, namespace, to_all_users): ) -def run_plugins(directory): +def run_plugins(directory, hook_value: HookValue): network_file_path = directory / NETWORK_FILE with network_file_path.open() as f: - network_file = yaml.safe_load(f) + network_file = yaml.safe_load(f) or {} + if not isinstance(network_file, dict): + raise ValueError(f"Invalid network file structure: {network_file_path}") - plugins = network_file.get("plugins") or [] + plugins_section = network_file.get("plugins", {}) + plugins = plugins_section.get(hook_value.value) or [] for plugin_cmd in plugins: - fully_qualified_cmd = f"{network_file_path.parent}/{plugin_cmd}" # relative to network.yaml - print(fully_qualified_cmd) - print(run_command(fully_qualified_cmd)) + fully_qualified_cmd = network_file_path.parent / plugin_cmd # relative to network.yaml + print(f"Plugin command: {fully_qualified_cmd}") + print(run_command(str(fully_qualified_cmd))) def check_logging_required(directory: Path): diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index af0b8c9a0..32043805e 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -54,4 +54,7 @@ nodes: lnd: true plugins: - - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + preDeploy: + - "../../../resources/plugins/simln/plugin.py" + postDeploy: + - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" From 01966437cb9b436e20a5575cc67f7205f4b4c5cf Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 10 Dec 2024 14:28:27 -0600 Subject: [PATCH 19/35] fix: move run_plugins command --- src/warnet/deploy.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index addf40181..f97cdc57a 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -73,8 +73,6 @@ def _deploy(directory, debug, namespace, to_all_users): """Deploy a warnet with topology loaded from """ directory = Path(directory) - run_plugins(directory, HookValue.PRE_DEPLOY) - if to_all_users: namespaces = get_namespaces_by_type(WARGAMES_NAMESPACE_PREFIX) processes = [] @@ -87,6 +85,7 @@ def _deploy(directory, debug, namespace, to_all_users): return if (directory / NETWORK_FILE).exists(): + run_plugins(directory, HookValue.PRE_DEPLOY) processes = [] # Deploy logging CRD first to avoid synchronisation issues deploy_logging_crd(directory, debug) From 2bb6bbc8eb088cdd3a116acac65c42a1ba44d20e Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 11 Dec 2024 13:52:18 -0600 Subject: [PATCH 20/35] update relative path in plugin doc Also, add newline to make deploy look a little better --- resources/plugins/simln/README.md | 2 +- src/warnet/deploy.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index e7bb432d1..5aa5379b1 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -92,7 +92,7 @@ nodes: plugins: postDeploy: # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `plugin.py` file and `network.yaml` file may differ than what is shown below. - - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + - "../../plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" ```` diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index f97cdc57a..c75005ecf 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -86,6 +86,7 @@ def _deploy(directory, debug, namespace, to_all_users): if (directory / NETWORK_FILE).exists(): run_plugins(directory, HookValue.PRE_DEPLOY) + processes = [] # Deploy logging CRD first to avoid synchronisation issues deploy_logging_crd(directory, debug) From 89f4d1924a12adf0f15e6e6f4c4c0daa9ef156e4 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 12 Dec 2024 14:57:13 -0600 Subject: [PATCH 21/35] add preNode/postNode; add exec/waitFor --- docs/plugins.md | 13 +++++++++- src/warnet/constants.py | 7 ++++++ src/warnet/deploy.py | 51 ++++++++++++++++++++++++++++++++++++--- src/warnet/process.py | 19 +++++++++++++++ test/data/ln/network.yaml | 10 +++++++- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 94b1b45ae..edd31934f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -11,7 +11,18 @@ nodes: <> plugins: - - path/to/plugin/file/relative/to/the/network/dot/yaml/file/plugin.py + preNode: # Run commands before each node launches + - "echo This is preNode" # This command is a simple string + postNode: # Run commands after each node launches + - exec: "echo This is also postNode, but we waited for 'warnet status'" # This command is also a simple string ... + waitFor: "warnet status" # ... but it will execute after this command completes successfully + - exec: "echo This is postNode" # Simply using 'exec' also just works + preDeploy: # Run commands before Warnet runs the bulk of its `deploy` code + - "echo This is preDeploy" + postDeploy: # Run these commands after Warnet has finished the bulk of its `deploy` code + - "../../plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + - exec: "../../plugins/simln/plugin.py list-pod-names" + waitFor: "../../plugins/simln/plugin.py get-example-activity" ```` Warnet will execute these plugin commands after each invocation of `warnet deploy`. diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 9c41c8569..e769472f4 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -30,6 +30,13 @@ class HookValue(Enum): PRE_DEPLOY = "preDeploy" POST_DEPLOY = "postDeploy" + PRE_NODE = "preNode" + POST_NODE = "postNode" + + +class HookOptions(Enum): + EXEC = "exec" + WAIT_FOR = "waitFor" # Directories and files for non-python assets, e.g., helm charts, example scenarios, default configs diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index c75005ecf..7b5b23a29 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -1,3 +1,4 @@ +import os import subprocess import sys import tempfile @@ -24,6 +25,7 @@ NETWORK_FILE, SCENARIOS_DIR, WARGAMES_NAMESPACE_PREFIX, + HookOptions, HookValue, ) from .control import _run @@ -35,7 +37,7 @@ wait_for_ingress_controller, wait_for_pod_ready, ) -from .process import run_command, stream_command +from .process import run_command, stream_command, wait_for_run HINT = "\nAre you trying to run a scenario? See `warnet run --help`" @@ -129,6 +131,12 @@ def _deploy(directory, debug, namespace, to_all_users): def run_plugins(directory, hook_value: HookValue): + """ " Run the plugin commands within a given hook value""" + + def is_relative(path: str) -> bool: + """Determine if the path is a command or a path to a command""" + return os.path.dirname(path) != "" + network_file_path = directory / NETWORK_FILE with network_file_path.open() as f: @@ -139,9 +147,39 @@ def run_plugins(directory, hook_value: HookValue): plugins_section = network_file.get("plugins", {}) plugins = plugins_section.get(hook_value.value) or [] for plugin_cmd in plugins: - fully_qualified_cmd = network_file_path.parent / plugin_cmd # relative to network.yaml - print(f"Plugin command: {fully_qualified_cmd}") - print(run_command(str(fully_qualified_cmd))) + match plugin_cmd: + case {HookOptions.EXEC.value: cmd, HookOptions.WAIT_FOR.value: predicate}: + if is_relative(cmd): + cmd = network_file_path.parent / cmd + print(f"{HookOptions.EXEC.value}: {cmd}") + + if is_relative(predicate): + predicate = network_file_path.parent / predicate + print(f"{HookOptions.WAIT_FOR.value}: {predicate}") + + wait_for_run(str(predicate)) + print(run_command(str(cmd))) + + case {HookOptions.EXEC.value: cmd}: + if is_relative(cmd): + cmd = network_file_path.parent / cmd + print(f"{HookOptions.EXEC.value}: {cmd}") + print(run_command(str(cmd))) + + case str(): + cmd = plugin_cmd + if is_relative(cmd): + cmd = network_file_path.parent / plugin_cmd + print(f"{cmd}") + print(run_command(str(cmd))) + + case _: + print( + f"The following plugin command does not match known plugin command structures: {plugin_cmd}" + ) + print(f"Known hook values: {[v.value for v in HookValue]}") + print(f"Known hook options: {[v.value for v in HookOptions]}") + sys.exit(1) def check_logging_required(directory: Path): @@ -358,9 +396,14 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str): temp_override_file_path = Path(temp_file.name) cmd = f"{cmd} -f {temp_override_file_path}" + run_plugins(directory, HookValue.PRE_NODE) + if not stream_command(cmd): click.echo(f"Failed to run Helm command: {cmd}") return + + run_plugins(directory, HookValue.POST_NODE) + except Exception as e: click.echo(f"Error: {e}") return diff --git a/src/warnet/process.py b/src/warnet/process.py index 626124b71..068436fc2 100644 --- a/src/warnet/process.py +++ b/src/warnet/process.py @@ -1,4 +1,5 @@ import subprocess +from time import sleep def run_command(command: str) -> str: @@ -29,3 +30,21 @@ def stream_command(command: str) -> bool: if return_code != 0: raise Exception(message) return True + + +def wait_for_run(predicate, timeout=5 * 60, interval=5): + print(f"Waiting for predicate with timeout {timeout}s and interval {interval}s") + print(predicate) + while timeout > 0: + try: + if run_command(predicate): + return + except Exception: + pass + sleep(interval) + timeout -= interval + import inspect + + raise Exception( + f"Timed out waiting for Truth from predicate: {inspect.getsource(predicate).strip()}" + ) diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 32043805e..fd7dcdc45 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -54,7 +54,15 @@ nodes: lnd: true plugins: + preNode: + - "echo This is preNode" + postNode: + - exec: "echo This is postNode" + - exec: "echo This is also postNode, but we waited for 'warnet status'" + waitFor: "warnet status" preDeploy: - - "../../../resources/plugins/simln/plugin.py" + - "echo This is preDeploy" postDeploy: - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + - exec: "../../../resources/plugins/simln/plugin.py list-pod-names" + waitFor: "../../../resources/plugins/simln/plugin.py get-example-activity" From 98c002d267826673f89dbb7139af96d6369f5d78 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 12 Dec 2024 22:44:09 -0600 Subject: [PATCH 22/35] use entrypoint for plugins --- resources/plugins/simln/README.md | 17 +++--------- resources/plugins/simln/plugin.py | 43 +++++++++++++++++++++++++++---- src/warnet/constants.py | 5 ---- src/warnet/deploy.py | 41 ++++++++--------------------- test/data/ln/network.yaml | 15 +++-------- 5 files changed, 56 insertions(+), 65 deletions(-) diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index 5aa5379b1..59fd69229 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -91,24 +91,13 @@ nodes: plugins: postDeploy: - # Take note: the path to the plugin file is relative to the `network.yaml` file. The location of your `plugin.py` file and `network.yaml` file may differ than what is shown below. - - "../../plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" + simln: + entrypoint: "../../plugins/simln" # This is the path to the simln plugin folder (relative to the network.yaml file). + activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' ```` -### preDeploy and postDeploy -When using `warnet deploy `, Warnet will look for a `network.yaml` in the network folder, and it will deploy the nodes listed in the `nodes` section of the file. We can choose to execute our plugin functions before Warnet deploys the nodes by including those functions in the `preDeploy` section of the `network.yaml` file. We can also choose to run plugin commands after Warnet deploys the nodes by including the function in the `postDeploy` section. - -````yaml -plugins: - preDeploy: - - path/to/plugin.py setup_function - postDeploy: - - path/to/plugin.py run - - path/to/other/plugin.py run -```` - ## Generating your own SimLn image The SimLN plugin fetches a SimLN docker image from dockerhub. You can generate your own docker image if you choose: diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index a9e580d92..604c00603 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 - import json import logging import time from pathlib import Path import click +import yaml from kubernetes.stream import stream # When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" @@ -31,7 +31,7 @@ PLUGIN_DIR_TAG = "plugin_dir" -class SimLNError(Exception): +class PluginError(Exception): pass @@ -56,6 +56,38 @@ def simln(ctx): ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) +@simln.command() +@click.argument("network_file_path", type=str) +@click.argument("hook_value", type=str) +@click.pass_context +def entrypoint(ctx, network_file_path: str, hook_value: str): + """Plugin entrypoint""" + network_file_path = Path(network_file_path) + with network_file_path.open() as f: + network_file = yaml.safe_load(f) or {} + if not isinstance(network_file, dict): + raise ValueError(f"Invalid network file structure: {network_file_path}") + + plugins_section = network_file.get("plugins", {}) + hook_section = plugins_section.get(hook_value, {}) + + plugin_name = Path(__file__).resolve().parent.stem + plugin_data = hook_section.get(plugin_name) + if not plugin_data: + raise PluginError(f"Could not find {plugin_name} in {network_file_path}") + + _entrypoint(ctx, plugin_data) + + +def _entrypoint(ctx, plugin_data: dict): + """ "Called by entrypoint""" + # write your plugin startup commands here + activity = plugin_data.get("activity") + activity = json.loads(activity) + print(activity) + _launch_activity(activity, ctx.obj.get(PLUGIN_DIR_TAG)) + + # The group name is then used in decorators to create commands. These commands are # available to users when they access your plugin from the command line. @simln.command() @@ -82,7 +114,7 @@ def _get_example_activity() -> list[dict]: pod_a = pods[1].metadata.name pod_b = pods[2].metadata.name except Exception as err: - raise SimLNError( + raise PluginError( "Could not access the lightning nodes needed for the example.\n Try deploying some." ) from err return [{"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000}] @@ -116,10 +148,11 @@ def _launch_activity(activity: list[dict], plugin_dir: str) -> str: name = f"simln-{timestamp}" command = f"helm upgrade --install {timestamp} {plugin_dir}/charts/simln" - run_command(command) + run_command(command) activity_json = _generate_activity_json(activity) wait_for_init(name, namespace=get_default_namespace(), quiet=True) + if write_file_to_container( name, "init", @@ -130,7 +163,7 @@ def _launch_activity(activity: list[dict], plugin_dir: str) -> str: ): return name else: - raise SimLNError(f"Could not write sim.json to the init container: {name}") + raise PluginError(f"Could not write sim.json to the init container: {name}") def _generate_activity_json(activity: list[dict]) -> str: diff --git a/src/warnet/constants.py b/src/warnet/constants.py index e769472f4..033be0739 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -34,11 +34,6 @@ class HookValue(Enum): POST_NODE = "postNode" -class HookOptions(Enum): - EXEC = "exec" - WAIT_FOR = "waitFor" - - # Directories and files for non-python assets, e.g., helm charts, example scenarios, default configs SRC_DIR = files("warnet") RESOURCES_DIR = files("resources") diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 7b5b23a29..8ae033298 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -25,7 +25,6 @@ NETWORK_FILE, SCENARIOS_DIR, WARGAMES_NAMESPACE_PREFIX, - HookOptions, HookValue, ) from .control import _run @@ -37,7 +36,7 @@ wait_for_ingress_controller, wait_for_pod_ready, ) -from .process import run_command, stream_command, wait_for_run +from .process import run_command, stream_command HINT = "\nAre you trying to run a scenario? See `warnet run --help`" @@ -145,40 +144,22 @@ def is_relative(path: str) -> bool: raise ValueError(f"Invalid network file structure: {network_file_path}") plugins_section = network_file.get("plugins", {}) - plugins = plugins_section.get(hook_value.value) or [] - for plugin_cmd in plugins: + hook_section = plugins_section.get(hook_value.value, {}) + for plugin_cmd in hook_section.items(): match plugin_cmd: - case {HookOptions.EXEC.value: cmd, HookOptions.WAIT_FOR.value: predicate}: - if is_relative(cmd): - cmd = network_file_path.parent / cmd - print(f"{HookOptions.EXEC.value}: {cmd}") - - if is_relative(predicate): - predicate = network_file_path.parent / predicate - print(f"{HookOptions.WAIT_FOR.value}: {predicate}") - - wait_for_run(str(predicate)) - print(run_command(str(cmd))) - - case {HookOptions.EXEC.value: cmd}: - if is_relative(cmd): - cmd = network_file_path.parent / cmd - print(f"{HookOptions.EXEC.value}: {cmd}") - print(run_command(str(cmd))) - - case str(): - cmd = plugin_cmd - if is_relative(cmd): - cmd = network_file_path.parent / plugin_cmd - print(f"{cmd}") - print(run_command(str(cmd))) + case (str(), dict()): + try: + entrypoint_path = Path(plugin_cmd[1].get("entrypoint")) + except Exception as err: + raise SyntaxError("Each plugin must have an 'entrypoint'") from err + cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value}" + print(f"Command: {cmd}") + print(run_command(cmd)) case _: print( f"The following plugin command does not match known plugin command structures: {plugin_cmd}" ) - print(f"Known hook values: {[v.value for v in HookValue]}") - print(f"Known hook options: {[v.value for v in HookOptions]}") sys.exit(1) diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index fd7dcdc45..6abb4caaf 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -54,15 +54,8 @@ nodes: lnd: true plugins: - preNode: - - "echo This is preNode" - postNode: - - exec: "echo This is postNode" - - exec: "echo This is also postNode, but we waited for 'warnet status'" - waitFor: "warnet status" - preDeploy: - - "echo This is preDeploy" postDeploy: - - "../../../resources/plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" - - exec: "../../../resources/plugins/simln/plugin.py list-pod-names" - waitFor: "../../../resources/plugins/simln/plugin.py get-example-activity" + simln: + entrypoint: "../../../resources/plugins/simln" + activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + From 7817cc7aafc2581ba0116b0f1c0c1f6a981d5e28 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 12 Dec 2024 23:26:16 -0600 Subject: [PATCH 23/35] parallelize run_plugins; add pre/post network --- src/warnet/constants.py | 2 ++ src/warnet/deploy.py | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 033be0739..6d6eac313 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -32,6 +32,8 @@ class HookValue(Enum): POST_DEPLOY = "postDeploy" PRE_NODE = "preNode" POST_NODE = "postNode" + PRE_NETWORK = "preNetwork" + POST_NETWORK = "postNetwork" # Directories and files for non-python assets, e.g., helm charts, example scenarios, default configs diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 8ae033298..fad9d8e06 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -1,4 +1,3 @@ -import os import subprocess import sys import tempfile @@ -96,6 +95,8 @@ def _deploy(directory, debug, namespace, to_all_users): logging_process.start() processes.append(logging_process) + run_plugins(directory, HookValue.PRE_NETWORK) + network_process = Process(target=deploy_network, args=(directory, debug, namespace)) network_process.start() @@ -110,6 +111,8 @@ def _deploy(directory, debug, namespace, to_all_users): # Wait for the network process to complete network_process.join() + run_plugins(directory, HookValue.POST_NETWORK) + # Start the fork observer process immediately after network process completes fork_observer_process = Process(target=deploy_fork_observer, args=(directory, debug)) fork_observer_process.start() @@ -130,11 +133,7 @@ def _deploy(directory, debug, namespace, to_all_users): def run_plugins(directory, hook_value: HookValue): - """ " Run the plugin commands within a given hook value""" - - def is_relative(path: str) -> bool: - """Determine if the path is a command or a path to a command""" - return os.path.dirname(path) != "" + """Run the plugin commands within a given hook value""" network_file_path = directory / NETWORK_FILE @@ -143,6 +142,8 @@ def is_relative(path: str) -> bool: if not isinstance(network_file, dict): raise ValueError(f"Invalid network file structure: {network_file_path}") + processes = [] + plugins_section = network_file.get("plugins", {}) hook_section = plugins_section.get(hook_value.value, {}) for plugin_cmd in hook_section.items(): @@ -152,9 +153,10 @@ def is_relative(path: str) -> bool: entrypoint_path = Path(plugin_cmd[1].get("entrypoint")) except Exception as err: raise SyntaxError("Each plugin must have an 'entrypoint'") from err + cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value}" - print(f"Command: {cmd}") - print(run_command(cmd)) + process = Process(target=run_command, args=(cmd,)) + processes.append(process) case _: print( @@ -162,6 +164,12 @@ def is_relative(path: str) -> bool: ) sys.exit(1) + for process in processes: + process.start() + + for process in processes: + process.join() + def check_logging_required(directory: Path): # check if node-defaults has logging or metrics enabled From dc05443422f45d3ef4e7f7b78c294a6e57b75872 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 13 Dec 2024 02:06:11 -0600 Subject: [PATCH 24/35] allow plugins to take namespace and nargs Also, add a hello-world test --- resources/plugins/simln/plugin.py | 21 ++- src/warnet/deploy.py | 16 +- test/data/ln/network.yaml | 23 +++ .../plugins/hello/charts/hello/Chart.yaml | 5 + .../hello/charts/hello/templates/pod.yaml | 15 ++ .../plugins/hello/charts/hello/values.yaml | 2 + test/data/plugins/hello/plugin.py | 159 ++++++++++++++++++ test/simln_test.py | 19 +++ 8 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 test/data/plugins/hello/charts/hello/Chart.yaml create mode 100644 test/data/plugins/hello/charts/hello/templates/pod.yaml create mode 100644 test/data/plugins/hello/charts/hello/values.yaml create mode 100755 test/data/plugins/hello/plugin.py diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 604c00603..6cd97c1eb 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -10,7 +10,7 @@ # When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" # tag for "lightning" nodes is stored in LIGHTNING_MISSION. -from warnet.constants import LIGHTNING_MISSION +from warnet.constants import LIGHTNING_MISSION, HookValue from warnet.k8s import ( download, get_default_namespace, @@ -56,13 +56,24 @@ def simln(ctx): ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) +# Each Warnet plugin must have an entrypoint function which takes a network_file_path and a +# hook_value. Possible hook values can be found in the HookValue enum. It also takes a namespace +# value and a variable number of arguments which is used by, for example, preNode and postNode to +# pass along node names. @simln.command() @click.argument("network_file_path", type=str) @click.argument("hook_value", type=str) +@click.argument("namespace", type=str) +@click.argument("nargs", nargs=-1) @click.pass_context -def entrypoint(ctx, network_file_path: str, hook_value: str): +def entrypoint(ctx, network_file_path: str, hook_value: str, namespace: str, nargs): """Plugin entrypoint""" + assert hook_value in { + item.value for item in HookValue + }, f"{hook_value} is not a valid HookValue" + network_file_path = Path(network_file_path) + with network_file_path.open() as f: network_file = yaml.safe_load(f) or {} if not isinstance(network_file, dict): @@ -76,11 +87,11 @@ def entrypoint(ctx, network_file_path: str, hook_value: str): if not plugin_data: raise PluginError(f"Could not find {plugin_name} in {network_file_path}") - _entrypoint(ctx, plugin_data) + _entrypoint(ctx, plugin_data, HookValue(hook_value), namespace, nargs) -def _entrypoint(ctx, plugin_data: dict): - """ "Called by entrypoint""" +def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, nargs): + """Called by entrypoint""" # write your plugin startup commands here activity = plugin_data.get("activity") activity = json.loads(activity) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index fad9d8e06..d5a40b8fa 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -85,7 +85,7 @@ def _deploy(directory, debug, namespace, to_all_users): return if (directory / NETWORK_FILE).exists(): - run_plugins(directory, HookValue.PRE_DEPLOY) + run_plugins(directory, HookValue.PRE_DEPLOY, namespace) processes = [] # Deploy logging CRD first to avoid synchronisation issues @@ -95,7 +95,7 @@ def _deploy(directory, debug, namespace, to_all_users): logging_process.start() processes.append(logging_process) - run_plugins(directory, HookValue.PRE_NETWORK) + run_plugins(directory, HookValue.PRE_NETWORK, namespace) network_process = Process(target=deploy_network, args=(directory, debug, namespace)) network_process.start() @@ -111,7 +111,7 @@ def _deploy(directory, debug, namespace, to_all_users): # Wait for the network process to complete network_process.join() - run_plugins(directory, HookValue.POST_NETWORK) + run_plugins(directory, HookValue.POST_NETWORK, namespace) # Start the fork observer process immediately after network process completes fork_observer_process = Process(target=deploy_fork_observer, args=(directory, debug)) @@ -122,7 +122,7 @@ def _deploy(directory, debug, namespace, to_all_users): for p in processes: p.join() - run_plugins(directory, HookValue.POST_DEPLOY) + run_plugins(directory, HookValue.POST_DEPLOY, namespace) elif (directory / NAMESPACES_FILE).exists(): deploy_namespaces(directory) @@ -132,7 +132,7 @@ def _deploy(directory, debug, namespace, to_all_users): ) -def run_plugins(directory, hook_value: HookValue): +def run_plugins(directory, hook_value: HookValue, namespace, *args): """Run the plugin commands within a given hook value""" network_file_path = directory / NETWORK_FILE @@ -154,7 +154,7 @@ def run_plugins(directory, hook_value: HookValue): except Exception as err: raise SyntaxError("Each plugin must have an 'entrypoint'") from err - cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value}" + cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value} {namespace} {' '.join(map(str, args))}" process = Process(target=run_command, args=(cmd,)) processes.append(process) @@ -385,13 +385,13 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str): temp_override_file_path = Path(temp_file.name) cmd = f"{cmd} -f {temp_override_file_path}" - run_plugins(directory, HookValue.PRE_NODE) + run_plugins(directory, HookValue.PRE_NODE, namespace, node_name) if not stream_command(cmd): click.echo(f"Failed to run Helm command: {cmd}") return - run_plugins(directory, HookValue.POST_NODE) + run_plugins(directory, HookValue.POST_NODE, namespace, node_name) except Exception as e: click.echo(f"Error: {e}") diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 6abb4caaf..562986389 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -54,8 +54,31 @@ nodes: lnd: true plugins: + preDeploy: + hello: + entrypoint: "../plugins/hello" + podName: "hello-pre-deploy" + helloTo: "preDeploy!" postDeploy: simln: entrypoint: "../../../resources/plugins/simln" activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + preNode: + hello: + entrypoint: "../plugins/hello" + helloTo: "preNode!" + postNode: + hello: + entrypoint: "../plugins/hello" + helloTo: "preNode!" + preNetwork: + hello: + entrypoint: "../plugins/hello" + helloTo: "preNetwork!" + podName: "hello-pre-network" + postNetwork: + hello: + entrypoint: "../plugins/hello" + helloTo: "postNetwork!" + podName: "hello-post-network" diff --git a/test/data/plugins/hello/charts/hello/Chart.yaml b/test/data/plugins/hello/charts/hello/Chart.yaml new file mode 100644 index 000000000..abd94467e --- /dev/null +++ b/test/data/plugins/hello/charts/hello/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: hello-chart +description: A Helm chart for a hello Pod +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/test/data/plugins/hello/charts/hello/templates/pod.yaml b/test/data/plugins/hello/charts/hello/templates/pod.yaml new file mode 100644 index 000000000..ba5319670 --- /dev/null +++ b/test/data/plugins/hello/charts/hello/templates/pod.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ .Values.podName }} + labels: + app: {{ .Chart.Name }} +spec: + restartPolicy: Never + containers: + - name: {{ .Values.podName }}-container + image: alpine:latest + command: ["sh", "-c"] + args: + - echo "Hello {{ .Values.helloTo }}"; + resources: {} \ No newline at end of file diff --git a/test/data/plugins/hello/charts/hello/values.yaml b/test/data/plugins/hello/charts/hello/values.yaml new file mode 100644 index 000000000..302da3c15 --- /dev/null +++ b/test/data/plugins/hello/charts/hello/values.yaml @@ -0,0 +1,2 @@ +podName: hello-pod +helloTo: "world" \ No newline at end of file diff --git a/test/data/plugins/hello/plugin.py b/test/data/plugins/hello/plugin.py new file mode 100755 index 000000000..6c29f5d5f --- /dev/null +++ b/test/data/plugins/hello/plugin.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import logging +from pathlib import Path +from typing import Optional + +import click +import yaml +from kubernetes.stream import stream + +from warnet.constants import HookValue +from warnet.k8s import ( + get_default_namespace, + get_static_client, +) +from warnet.process import run_command + +MISSION = "hello" +PRIMARY_CONTAINER = MISSION + +PLUGIN_DIR_TAG = "plugin_dir" + + +class PluginError(Exception): + pass + + +log = logging.getLogger(MISSION) +if not log.hasHandlers(): + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + console_handler.setFormatter(formatter) + log.addHandler(console_handler) +log.setLevel(logging.DEBUG) +log.propagate = True + + +@click.group() +@click.pass_context +def hello(ctx): + """Commands for the Hello plugin""" + ctx.ensure_object(dict) + plugin_dir = Path(__file__).resolve().parent + ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) + + +@hello.command() +@click.argument("network_file_path", type=str) +@click.argument("hook_value", type=str) +@click.argument("namespace", type=str) +@click.argument("nargs", nargs=-1) +@click.pass_context +def entrypoint(ctx, network_file_path: str, hook_value: str, namespace: str, nargs): + """Plugin entrypoint""" + assert hook_value in { + item.value for item in HookValue + }, f"{hook_value} is not a valid HookValue" + + network_file_path = Path(network_file_path) + + with network_file_path.open() as f: + network_file = yaml.safe_load(f) or {} + if not isinstance(network_file, dict): + raise ValueError(f"Invalid network file structure: {network_file_path}") + + plugins_section = network_file.get("plugins", {}) + hook_section = plugins_section.get(hook_value, {}) + + plugin_name = Path(__file__).resolve().parent.stem + plugin_data = hook_section.get(plugin_name) + if not plugin_data: + raise PluginError(f"Could not find {plugin_name} in {network_file_path}") + + _entrypoint(ctx, plugin_data, HookValue(hook_value), namespace, nargs) + + +def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, nargs): + """Called by entrypoint""" + match hook_value: + case ( + HookValue.PRE_NETWORK + | HookValue.POST_NETWORK + | HookValue.PRE_DEPLOY + | HookValue.POST_DEPLOY + ): + data = get_data(plugin_data) + if data: + _launch_pod(ctx, install_name=hook_value.value.lower() + "-hello", **data) + else: + _launch_pod(ctx, install_name=hook_value.value.lower() + "-hello") + case HookValue.PRE_NODE: + name = nargs[0] + "-pre-hello-pod" + _launch_pod(ctx, install_name=hook_value.value.lower() + "-" + name, podName=name) + case HookValue.POST_NODE: + name = nargs[0] + "-post-hello-pod" + _launch_pod(ctx, install_name=hook_value.value.lower() + "-" + name, podName=name) + + +def get_data(plugin_data: dict) -> Optional[dict]: + data = {key: plugin_data.get(key) for key in ("podName", "helloTo") if plugin_data.get(key)} + return data or None + + +def _launch_pod( + ctx, install_name: str = "hello", podName: str = "hello-pod", helloTo: str = "World!" +): + command = f"helm upgrade --install {install_name} {ctx.obj[PLUGIN_DIR_TAG]}/charts/hello --set podName={podName} --set helloTo={helloTo}" + log.info(command) + log.info(run_command(command)) + + +def _sh(pod, method: str, params: tuple[str, ...]) -> str: + namespace = get_default_namespace() + + sclient = get_static_client() + if params: + cmd = [method] + cmd.extend(params) + else: + cmd = [method] + try: + resp = stream( + sclient.connect_get_namespaced_pod_exec, + pod, + namespace, + container=PRIMARY_CONTAINER, + command=cmd, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + stdout = "" + stderr = "" + while resp.is_open(): + resp.update(timeout=1) + if resp.peek_stdout(): + stdout_chunk = resp.read_stdout() + stdout += stdout_chunk + if resp.peek_stderr(): + stderr_chunk = resp.read_stderr() + stderr += stderr_chunk + return stdout + stderr + except Exception as err: + print(f"Could not execute stream: {err}") + + +@hello.command(context_settings={"ignore_unknown_options": True}) +@click.argument("pod", type=str) +@click.argument("method", type=str) +@click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments +def sh(pod: str, method: str, params: tuple[str, ...]): + """Run shell commands in a pod""" + print(_sh(pod, method, params)) + + +if __name__ == "__main__": + hello() diff --git a/test/simln_test.py b/test/simln_test.py index 6d0ec06f6..eee7a9c87 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -25,6 +25,7 @@ def run_test(self): self.init_directory() self.deploy_with_plugin() self.copy_results() + self.assert_hello_plugin() finally: self.cleanup() @@ -83,6 +84,24 @@ def found_results_locally(self) -> bool: self.log.info(f"Did not find downloaded results in directory: {directory}.") return False + def assert_hello_plugin(self): + self.log.info("Waiting for the 'hello' plugin pods.") + wait_for_pod("hello-pre-deploy") # We don't use post-deploy (simln covers that) + wait_for_pod("hello-pre-network") + wait_for_pod("hello-post-network") + wait_for_pod("tank-0000-post-hello-pod") + wait_for_pod("tank-0000-pre-hello-pod") + wait_for_pod("tank-0001-post-hello-pod") + wait_for_pod("tank-0001-pre-hello-pod") + wait_for_pod("tank-0002-post-hello-pod") + wait_for_pod("tank-0002-pre-hello-pod") + wait_for_pod("tank-0003-post-hello-pod") + wait_for_pod("tank-0003-pre-hello-pod") + wait_for_pod("tank-0004-post-hello-pod") + wait_for_pod("tank-0004-pre-hello-pod") + wait_for_pod("tank-0005-post-hello-pod") + wait_for_pod("tank-0005-pre-hello-pod") + if __name__ == "__main__": test = SimLNTest() From 08b8c32ee82f058fb0e3440a1df9d35eb07a3748 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 13 Dec 2024 02:07:32 -0600 Subject: [PATCH 25/35] preNode -> postNode --- test/data/ln/network.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 562986389..cb3c95396 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -70,7 +70,7 @@ plugins: postNode: hello: entrypoint: "../plugins/hello" - helloTo: "preNode!" + helloTo: "postNode!" preNetwork: hello: entrypoint: "../plugins/hello" From 0e33b48ac6c9a676bd0e15b3e2f84685c61c4c3c Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 13 Dec 2024 02:27:05 -0600 Subject: [PATCH 26/35] fixup docs a little; expand test a little --- docs/plugins.md | 43 ++++++++++++++++++++++++++++----------- test/data/ln/network.yaml | 4 ++++ test/simln_test.py | 3 ++- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index edd31934f..13741644a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -11,18 +11,37 @@ nodes: <> plugins: - preNode: # Run commands before each node launches - - "echo This is preNode" # This command is a simple string - postNode: # Run commands after each node launches - - exec: "echo This is also postNode, but we waited for 'warnet status'" # This command is also a simple string ... - waitFor: "warnet status" # ... but it will execute after this command completes successfully - - exec: "echo This is postNode" # Simply using 'exec' also just works - preDeploy: # Run commands before Warnet runs the bulk of its `deploy` code - - "echo This is preDeploy" - postDeploy: # Run these commands after Warnet has finished the bulk of its `deploy` code - - "../../plugins/simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'" - - exec: "../../plugins/simln/plugin.py list-pod-names" - waitFor: "../../plugins/simln/plugin.py get-example-activity" + preDeploy: + hello: + entrypoint: "../plugins/hello" + podName: "hello-pre-deploy" + helloTo: "preDeploy!" + postDeploy: + simln: + entrypoint: "../../../resources/plugins/simln" + activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + hello: + entrypoint: "../plugins/hello" + podName: "hello-post-deploy" + helloTo: "postDeploy!" + preNode: + hello: + entrypoint: "../plugins/hello" + helloTo: "preNode!" + postNode: + hello: + entrypoint: "../plugins/hello" + helloTo: "postNode!" + preNetwork: + hello: + entrypoint: "../plugins/hello" + helloTo: "preNetwork!" + podName: "hello-pre-network" + postNetwork: + hello: + entrypoint: "../plugins/hello" + helloTo: "postNetwork!" + podName: "hello-post-network" ```` Warnet will execute these plugin commands after each invocation of `warnet deploy`. diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index cb3c95396..9388f74f2 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -63,6 +63,10 @@ plugins: simln: entrypoint: "../../../resources/plugins/simln" activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + hello: + entrypoint: "../plugins/hello" + podName: "hello-post-deploy" + helloTo: "postDeploy!" preNode: hello: entrypoint: "../plugins/hello" diff --git a/test/simln_test.py b/test/simln_test.py index eee7a9c87..efcda7ab4 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -86,7 +86,8 @@ def found_results_locally(self) -> bool: def assert_hello_plugin(self): self.log.info("Waiting for the 'hello' plugin pods.") - wait_for_pod("hello-pre-deploy") # We don't use post-deploy (simln covers that) + wait_for_pod("hello-pre-deploy") + wait_for_pod("hello-post-deploy") wait_for_pod("hello-pre-network") wait_for_pod("hello-post-network") wait_for_pod("tank-0000-post-hello-pod") From ec17d249815f14285acda6c776359661521318b6 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 13 Dec 2024 12:19:07 -0600 Subject: [PATCH 27/35] address Max's feedback - Fix multithreading on macos - Create a separate network file to test the plugin system - Create useful messages in the console when doing plugin operations - Avoid reading the yaml file; instead pass along only those values needed --- resources/plugins/simln/plugin.py | 41 +++++------- src/warnet/constants.py | 13 ++++ src/warnet/deploy.py | 42 +++++++++--- test/data/ln/network.yaml | 34 ---------- test/data/plugins/hello/plugin.py | 54 +++++++-------- test/data/plugins/ln/network.yaml | 88 +++++++++++++++++++++++++ test/data/plugins/ln/node-defaults.yaml | 8 +++ test/simln_test.py | 2 +- 8 files changed, 189 insertions(+), 93 deletions(-) create mode 100644 test/data/plugins/ln/network.yaml create mode 100644 test/data/plugins/ln/node-defaults.yaml diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 6cd97c1eb..bff82818c 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -5,12 +5,11 @@ from pathlib import Path import click -import yaml from kubernetes.stream import stream # When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" # tag for "lightning" nodes is stored in LIGHTNING_MISSION. -from warnet.constants import LIGHTNING_MISSION, HookValue +from warnet.constants import LIGHTNING_MISSION, PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent from warnet.k8s import ( download, get_default_namespace, @@ -61,39 +60,35 @@ def simln(ctx): # value and a variable number of arguments which is used by, for example, preNode and postNode to # pass along node names. @simln.command() -@click.argument("network_file_path", type=str) -@click.argument("hook_value", type=str) -@click.argument("namespace", type=str) -@click.argument("nargs", nargs=-1) +@click.argument("plugin_content", type=str) +@click.argument("warnet_content", type=str) @click.pass_context -def entrypoint(ctx, network_file_path: str, hook_value: str, namespace: str, nargs): +def entrypoint(ctx, plugin_content: str, warnet_content: str): """Plugin entrypoint""" + plugin_content: dict = json.loads(plugin_content) + warnet_content: dict = json.loads(warnet_content) + + hook_value = warnet_content.get(WarnetContent.HOOK_VALUE.value) + assert hook_value in { item.value for item in HookValue }, f"{hook_value} is not a valid HookValue" - network_file_path = Path(network_file_path) - - with network_file_path.open() as f: - network_file = yaml.safe_load(f) or {} - if not isinstance(network_file, dict): - raise ValueError(f"Invalid network file structure: {network_file_path}") - - plugins_section = network_file.get("plugins", {}) - hook_section = plugins_section.get(hook_value, {}) + if warnet_content.get(PLUGIN_ANNEX): + for annex_member in [annex_item for annex_item in warnet_content.get(PLUGIN_ANNEX)]: + assert annex_member in { + item.value for item in AnnexMember + }, f"{annex_member} is not a valid AnnexMember" - plugin_name = Path(__file__).resolve().parent.stem - plugin_data = hook_section.get(plugin_name) - if not plugin_data: - raise PluginError(f"Could not find {plugin_name} in {network_file_path}") + warnet_content[WarnetContent.HOOK_VALUE.value] = HookValue(hook_value) - _entrypoint(ctx, plugin_data, HookValue(hook_value), namespace, nargs) + _entrypoint(ctx, plugin_content, warnet_content) -def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, nargs): +def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): """Called by entrypoint""" # write your plugin startup commands here - activity = plugin_data.get("activity") + activity = plugin_content.get("activity") activity = json.loads(activity) print(activity) _launch_activity(activity, ctx.obj.get(PLUGIN_DIR_TAG)) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 6d6eac313..017c9a749 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -36,6 +36,19 @@ class HookValue(Enum): POST_NETWORK = "postNetwork" +class WarnetContent(Enum): + HOOK_VALUE = "hook_value" + NAMESPACE = "namespace" + ANNEX = "annex" + + +class AnnexMember(Enum): + NODE_NAME = "node_name" + + +PLUGIN_ANNEX = "annex" + + # Directories and files for non-python assets, e.g., helm charts, example scenarios, default configs SRC_DIR = files("warnet") RESOURCES_DIR = files("resources") diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index d5a40b8fa..3b3d90f8a 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -1,3 +1,4 @@ +import json import subprocess import sys import tempfile @@ -22,9 +23,12 @@ NAMESPACES_CHART_LOCATION, NAMESPACES_FILE, NETWORK_FILE, + PLUGIN_ANNEX, SCENARIOS_DIR, WARGAMES_NAMESPACE_PREFIX, + AnnexMember, HookValue, + WarnetContent, ) from .control import _run from .k8s import ( @@ -132,7 +136,7 @@ def _deploy(directory, debug, namespace, to_all_users): ) -def run_plugins(directory, hook_value: HookValue, namespace, *args): +def run_plugins(directory, hook_value: HookValue, namespace, annex: Optional[dict] = None): """Run the plugin commands within a given hook value""" network_file_path = directory / NETWORK_FILE @@ -146,29 +150,44 @@ def run_plugins(directory, hook_value: HookValue, namespace, *args): plugins_section = network_file.get("plugins", {}) hook_section = plugins_section.get(hook_value.value, {}) - for plugin_cmd in hook_section.items(): - match plugin_cmd: + for plugin_name, plugin_content in hook_section.items(): + match (plugin_name, plugin_content): case (str(), dict()): try: - entrypoint_path = Path(plugin_cmd[1].get("entrypoint")) + entrypoint_path = Path(plugin_content.get("entrypoint")) except Exception as err: raise SyntaxError("Each plugin must have an 'entrypoint'") from err - cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value} {namespace} {' '.join(map(str, args))}" + warnet_content = { + WarnetContent.HOOK_VALUE.value: hook_value.value, + WarnetContent.NAMESPACE.value: namespace, + PLUGIN_ANNEX: annex, + } + + cmd = ( + f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint " + f"'{json.dumps(plugin_content)}' '{json.dumps(warnet_content)}'" + ) + print( + f"Queuing {hook_value.value} plugin command: {plugin_name} with {plugin_content}" + ) + process = Process(target=run_command, args=(cmd,)) processes.append(process) case _: print( - f"The following plugin command does not match known plugin command structures: {plugin_cmd}" + f"The following plugin command does not match known plugin command structures: {plugin_name} {plugin_content}" ) sys.exit(1) + print(f"Starting {hook_value.value} plugins") for process in processes: process.start() for process in processes: process.join() + print(f"Completed {hook_value.value} plugins") def check_logging_required(directory: Path): @@ -385,13 +404,20 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str): temp_override_file_path = Path(temp_file.name) cmd = f"{cmd} -f {temp_override_file_path}" - run_plugins(directory, HookValue.PRE_NODE, namespace, node_name) + run_plugins( + directory, HookValue.PRE_NODE, namespace, annex={AnnexMember.NODE_NAME.value: node_name} + ) if not stream_command(cmd): click.echo(f"Failed to run Helm command: {cmd}") return - run_plugins(directory, HookValue.POST_NODE, namespace, node_name) + run_plugins( + directory, + HookValue.POST_NODE, + namespace, + annex={AnnexMember.NODE_NAME.value: node_name}, + ) except Exception as e: click.echo(f"Error: {e}") diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 9388f74f2..792861da2 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -52,37 +52,3 @@ nodes: - tank-0000 ln: lnd: true - -plugins: - preDeploy: - hello: - entrypoint: "../plugins/hello" - podName: "hello-pre-deploy" - helloTo: "preDeploy!" - postDeploy: - simln: - entrypoint: "../../../resources/plugins/simln" - activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' - hello: - entrypoint: "../plugins/hello" - podName: "hello-post-deploy" - helloTo: "postDeploy!" - preNode: - hello: - entrypoint: "../plugins/hello" - helloTo: "preNode!" - postNode: - hello: - entrypoint: "../plugins/hello" - helloTo: "postNode!" - preNetwork: - hello: - entrypoint: "../plugins/hello" - helloTo: "preNetwork!" - podName: "hello-pre-network" - postNetwork: - hello: - entrypoint: "../plugins/hello" - helloTo: "postNetwork!" - podName: "hello-post-network" - diff --git a/test/data/plugins/hello/plugin.py b/test/data/plugins/hello/plugin.py index 6c29f5d5f..f25b00d5a 100755 --- a/test/data/plugins/hello/plugin.py +++ b/test/data/plugins/hello/plugin.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 +import json import logging from pathlib import Path from typing import Optional import click -import yaml from kubernetes.stream import stream -from warnet.constants import HookValue +from warnet.constants import PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent from warnet.k8s import ( get_default_namespace, get_static_client, @@ -45,37 +45,35 @@ def hello(ctx): @hello.command() -@click.argument("network_file_path", type=str) -@click.argument("hook_value", type=str) -@click.argument("namespace", type=str) -@click.argument("nargs", nargs=-1) +@click.argument("plugin_content", type=str) +@click.argument("warnet_content", type=str) @click.pass_context -def entrypoint(ctx, network_file_path: str, hook_value: str, namespace: str, nargs): +def entrypoint(ctx, plugin_content: str, warnet_content: str): """Plugin entrypoint""" + plugin_content: dict = json.loads(plugin_content) + warnet_content: dict = json.loads(warnet_content) + + hook_value = warnet_content.get(WarnetContent.HOOK_VALUE.value) + assert hook_value in { item.value for item in HookValue }, f"{hook_value} is not a valid HookValue" - network_file_path = Path(network_file_path) - - with network_file_path.open() as f: - network_file = yaml.safe_load(f) or {} - if not isinstance(network_file, dict): - raise ValueError(f"Invalid network file structure: {network_file_path}") + if warnet_content.get(PLUGIN_ANNEX): + for annex_member in [annex_item for annex_item in warnet_content.get(PLUGIN_ANNEX)]: + assert annex_member in { + item.value for item in AnnexMember + }, f"{annex_member} is not a valid AnnexMember" - plugins_section = network_file.get("plugins", {}) - hook_section = plugins_section.get(hook_value, {}) + warnet_content[WarnetContent.HOOK_VALUE.value] = HookValue(hook_value) - plugin_name = Path(__file__).resolve().parent.stem - plugin_data = hook_section.get(plugin_name) - if not plugin_data: - raise PluginError(f"Could not find {plugin_name} in {network_file_path}") + _entrypoint(ctx, plugin_content, warnet_content) - _entrypoint(ctx, plugin_data, HookValue(hook_value), namespace, nargs) - -def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, nargs): +def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): """Called by entrypoint""" + hook_value = warnet_content[WarnetContent.HOOK_VALUE.value] + match hook_value: case ( HookValue.PRE_NETWORK @@ -83,21 +81,23 @@ def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, n | HookValue.PRE_DEPLOY | HookValue.POST_DEPLOY ): - data = get_data(plugin_data) + data = get_data(plugin_content) if data: _launch_pod(ctx, install_name=hook_value.value.lower() + "-hello", **data) else: _launch_pod(ctx, install_name=hook_value.value.lower() + "-hello") case HookValue.PRE_NODE: - name = nargs[0] + "-pre-hello-pod" + name = warnet_content[PLUGIN_ANNEX][AnnexMember.NODE_NAME.value] + "-pre-hello-pod" _launch_pod(ctx, install_name=hook_value.value.lower() + "-" + name, podName=name) case HookValue.POST_NODE: - name = nargs[0] + "-post-hello-pod" + name = warnet_content[PLUGIN_ANNEX][AnnexMember.NODE_NAME.value] + "-post-hello-pod" _launch_pod(ctx, install_name=hook_value.value.lower() + "-" + name, podName=name) -def get_data(plugin_data: dict) -> Optional[dict]: - data = {key: plugin_data.get(key) for key in ("podName", "helloTo") if plugin_data.get(key)} +def get_data(plugin_content: dict) -> Optional[dict]: + data = { + key: plugin_content.get(key) for key in ("podName", "helloTo") if plugin_content.get(key) + } return data or None diff --git a/test/data/plugins/ln/network.yaml b/test/data/plugins/ln/network.yaml new file mode 100644 index 000000000..7e11dfe74 --- /dev/null +++ b/test/data/plugins/ln/network.yaml @@ -0,0 +1,88 @@ +nodes: + - name: tank-0000 + addnode: + - tank-0001 + ln: + lnd: true + + - name: tank-0001 + addnode: + - tank-0002 + ln: + lnd: true + + - name: tank-0002 + addnode: + - tank-0000 + ln: + lnd: true + + - name: tank-0003 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + config: | + bitcoin.timelockdelta=33 + channels: + - id: + block: 300 + index: 1 + target: tank-0004-ln + capacity: 100000 + push_amt: 50000 + + - name: tank-0004 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + channels: + - id: + block: 300 + index: 2 + target: tank-0005-ln + capacity: 50000 + push_amt: 25000 + + - name: tank-0005 + addnode: + - tank-0000 + ln: + lnd: true + +plugins: + preDeploy: + hello: + entrypoint: "../hello" + podName: "hello-pre-deploy" + helloTo: "preDeploy!" + postDeploy: + simln: + entrypoint: "../../../../resources/plugins/simln" + activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + hello: + entrypoint: "../hello" + podName: "hello-post-deploy" + helloTo: "postDeploy!" + preNode: + hello: + entrypoint: "../hello" + helloTo: "preNode!" + postNode: + hello: + entrypoint: "../hello" + helloTo: "postNode!" + preNetwork: + hello: + entrypoint: "../hello" + helloTo: "preNetwork!" + podName: "hello-pre-network" + postNetwork: + hello: + entrypoint: "../hello" + helloTo: "postNetwork!" + podName: "hello-post-network" + diff --git a/test/data/plugins/ln/node-defaults.yaml b/test/data/plugins/ln/node-defaults.yaml new file mode 100644 index 000000000..884ad1343 --- /dev/null +++ b/test/data/plugins/ln/node-defaults.yaml @@ -0,0 +1,8 @@ +image: + repository: bitcoindevproject/bitcoin + pullPolicy: IfNotPresent + tag: "27.0" + +lnd: + defaultConfig: | + color=#000000 \ No newline at end of file diff --git a/test/simln_test.py b/test/simln_test.py index efcda7ab4..e6da58200 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -15,7 +15,7 @@ class SimLNTest(TestBase): def __init__(self): super().__init__() - self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" + self.network_dir = Path(os.path.dirname(__file__)) / "data" / "plugins" / "ln" self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" self.simln_exec = "plugins/simln/plugin.py" From fd80b3bf73a660e9247799c83b73cc49b73d1258 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 13 Dec 2024 13:38:55 -0600 Subject: [PATCH 28/35] update documentation and examples This includes a *hello* network on the networks folder --- .../networks/hello}/network.yaml | 25 ++-- .../networks/hello}/node-defaults.yaml | 0 resources/plugins/hello/README.md | 124 ++++++++++++++++++ .../plugins/hello/charts/hello/Chart.yaml | 0 .../hello/charts/hello/templates/pod.yaml | 0 .../plugins/hello/charts/hello/values.yaml | 0 .../plugins/hello/plugin.py | 78 ++++------- resources/plugins/simln/plugin.py | 21 --- test/simln_test.py | 4 +- 9 files changed, 165 insertions(+), 87 deletions(-) rename {test/data/plugins/ln => resources/networks/hello}/network.yaml (68%) rename {test/data/plugins/ln => resources/networks/hello}/node-defaults.yaml (100%) create mode 100644 resources/plugins/hello/README.md rename {test/data => resources}/plugins/hello/charts/hello/Chart.yaml (100%) rename {test/data => resources}/plugins/hello/charts/hello/templates/pod.yaml (100%) rename {test/data => resources}/plugins/hello/charts/hello/values.yaml (100%) rename {test/data => resources}/plugins/hello/plugin.py (67%) diff --git a/test/data/plugins/ln/network.yaml b/resources/networks/hello/network.yaml similarity index 68% rename from test/data/plugins/ln/network.yaml rename to resources/networks/hello/network.yaml index 7e11dfe74..f5acf0a83 100644 --- a/test/data/plugins/ln/network.yaml +++ b/resources/networks/hello/network.yaml @@ -53,36 +53,35 @@ nodes: ln: lnd: true -plugins: - preDeploy: +plugins: # Each plugin section has a number of hooks available (preDeploy, postDeploy, etc) + preDeploy: # For example, the preDeploy hook means it's plugin will run before all other deploy code hello: - entrypoint: "../hello" + entrypoint: "../../plugins/hello" # This entrypoint path is relative to the network.yaml file podName: "hello-pre-deploy" helloTo: "preDeploy!" postDeploy: - simln: - entrypoint: "../../../../resources/plugins/simln" - activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' hello: - entrypoint: "../hello" + entrypoint: "../../plugins/hello" podName: "hello-post-deploy" helloTo: "postDeploy!" - preNode: + simln: # You can have multiple plugins per hook + entrypoint: "../../plugins/simln" + activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + preNode: # preNode plugins run before each node is deployed hello: - entrypoint: "../hello" + entrypoint: "../../plugins/hello" helloTo: "preNode!" postNode: hello: - entrypoint: "../hello" + entrypoint: "../../plugins/hello" helloTo: "postNode!" preNetwork: hello: - entrypoint: "../hello" + entrypoint: "../../plugins/hello" helloTo: "preNetwork!" podName: "hello-pre-network" postNetwork: hello: - entrypoint: "../hello" + entrypoint: "../../plugins/hello" helloTo: "postNetwork!" podName: "hello-post-network" - diff --git a/test/data/plugins/ln/node-defaults.yaml b/resources/networks/hello/node-defaults.yaml similarity index 100% rename from test/data/plugins/ln/node-defaults.yaml rename to resources/networks/hello/node-defaults.yaml diff --git a/resources/plugins/hello/README.md b/resources/plugins/hello/README.md new file mode 100644 index 000000000..7d01ffad1 --- /dev/null +++ b/resources/plugins/hello/README.md @@ -0,0 +1,124 @@ +# Hello Plugin + +## Hello World! +*Hello* is an example plugin to demonstrate the features of Warnet's plugin architecture. + +## Usage +In your python virtual environment with Warnet installed and started, create a new Warnet user folder (follow the prompts): + +`$ warnet new user_folder` + +`$ cd user_folder` + +Deploy the *hello* network. + +`$ warnet deploy networks/hello` + +While that is launching, take a look inside the `networks/hello/network.yaml` file. You can also see the copy below which includes commentary on the structure of plugins in the `network.yaml` file. + +Also, take a look at the `plugins/hello/plugin.py` file to see how plugins work and to find out how to author your own plugin. + +To view the pods that the *hello* network launched, run `kubectl get all -A` + +To view the various "Hello World!" messages, run `kubectl logs pod/POD_NAME` + +### A `network.yaml` example +When you initialize a new Warnet network, Warnet will create a new `network.yaml` file. You can modify these files to fit your needs. + +For example, example `network.yaml` file includes the *hello* plugin, lightning nodes and the *simln* plugin. + +
+network.yaml + +````yaml +nodes: + - name: tank-0000 + addnode: + - tank-0001 + ln: + lnd: true + + - name: tank-0001 + addnode: + - tank-0002 + ln: + lnd: true + + - name: tank-0002 + addnode: + - tank-0000 + ln: + lnd: true + + - name: tank-0003 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + config: | + bitcoin.timelockdelta=33 + channels: + - id: + block: 300 + index: 1 + target: tank-0004-ln + capacity: 100000 + push_amt: 50000 + + - name: tank-0004 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + channels: + - id: + block: 300 + index: 2 + target: tank-0005-ln + capacity: 50000 + push_amt: 25000 + + - name: tank-0005 + addnode: + - tank-0000 + ln: + lnd: true + +plugins: # Each plugin section has a number of hooks available (preDeploy, postDeploy, etc) + preDeploy: # For example, the preDeploy hook means it's plugin will run before all other deploy code + hello: + entrypoint: "../../plugins/hello" # This entrypoint path is relative to the network.yaml file + podName: "hello-pre-deploy" + helloTo: "preDeploy!" + postDeploy: + hello: + entrypoint: "../../plugins/hello" + podName: "hello-post-deploy" + helloTo: "postDeploy!" + simln: # You can have multiple plugins per hook + entrypoint: "../../plugins/simln" + activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + preNode: # preNode plugins run before each node is deployed + hello: + entrypoint: "../../plugins/hello" + helloTo: "preNode!" + postNode: + hello: + entrypoint: "../../plugins/hello" + helloTo: "postNode!" + preNetwork: + hello: + entrypoint: "../../plugins/hello" + helloTo: "preNetwork!" + podName: "hello-pre-network" + postNetwork: + hello: + entrypoint: "../../plugins/hello" + helloTo: "postNetwork!" + podName: "hello-post-network" +```` + +
+ diff --git a/test/data/plugins/hello/charts/hello/Chart.yaml b/resources/plugins/hello/charts/hello/Chart.yaml similarity index 100% rename from test/data/plugins/hello/charts/hello/Chart.yaml rename to resources/plugins/hello/charts/hello/Chart.yaml diff --git a/test/data/plugins/hello/charts/hello/templates/pod.yaml b/resources/plugins/hello/charts/hello/templates/pod.yaml similarity index 100% rename from test/data/plugins/hello/charts/hello/templates/pod.yaml rename to resources/plugins/hello/charts/hello/templates/pod.yaml diff --git a/test/data/plugins/hello/charts/hello/values.yaml b/resources/plugins/hello/charts/hello/values.yaml similarity index 100% rename from test/data/plugins/hello/charts/hello/values.yaml rename to resources/plugins/hello/charts/hello/values.yaml diff --git a/test/data/plugins/hello/plugin.py b/resources/plugins/hello/plugin.py similarity index 67% rename from test/data/plugins/hello/plugin.py rename to resources/plugins/hello/plugin.py index f25b00d5a..fe8fbada6 100755 --- a/test/data/plugins/hello/plugin.py +++ b/resources/plugins/hello/plugin.py @@ -1,19 +1,16 @@ #!/usr/bin/env python3 import json import logging +from enum import Enum from pathlib import Path from typing import Optional import click -from kubernetes.stream import stream from warnet.constants import PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent -from warnet.k8s import ( - get_default_namespace, - get_static_client, -) from warnet.process import run_command +# Tt is common for Warnet objects to have a "mission" tag to query them in the cluster. MISSION = "hello" PRIMARY_CONTAINER = MISSION @@ -35,6 +32,23 @@ class PluginError(Exception): log.propagate = True +# Plugins look like this in the network.yaml file: +# +# plugins: +# hello: +# podName: "a-pod-name" +# helloTo: "World!" +# +# "podName" and "helloTo" are essentially dictionary keys, and it helps to keep those keys in an +# enum in order to prevent typos. +class PluginContent(Enum): + POD_NAME = ("podName",) + HELLO_TO = "helloTo" + + +# Warnet uses a python package called "click" to manage terminal interactions with the user. +# To use click, we must declare a click "group" by decorating a function named after the plugin. +# While optional, using click makes it easy for users to interact with your plugin. @click.group() @click.pass_context def hello(ctx): @@ -44,6 +58,9 @@ def hello(ctx): ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) +# Each Warnet plugin must have an entrypoint function which takes two JSON objects: plugin_content +# and warnet_content. We have seen the PluginContent enum above. Warnet also has a WarnetContent +# enum which holds the keys to the warnet_content dictionary. @hello.command() @click.argument("plugin_content", type=str) @click.argument("warnet_content", type=str) @@ -104,56 +121,13 @@ def get_data(plugin_content: dict) -> Optional[dict]: def _launch_pod( ctx, install_name: str = "hello", podName: str = "hello-pod", helloTo: str = "World!" ): - command = f"helm upgrade --install {install_name} {ctx.obj[PLUGIN_DIR_TAG]}/charts/hello --set podName={podName} --set helloTo={helloTo}" + command = ( + f"helm upgrade --install {install_name} {ctx.obj[PLUGIN_DIR_TAG]}/charts/hello " + f"--set podName={podName} --set helloTo={helloTo}" + ) log.info(command) log.info(run_command(command)) -def _sh(pod, method: str, params: tuple[str, ...]) -> str: - namespace = get_default_namespace() - - sclient = get_static_client() - if params: - cmd = [method] - cmd.extend(params) - else: - cmd = [method] - try: - resp = stream( - sclient.connect_get_namespaced_pod_exec, - pod, - namespace, - container=PRIMARY_CONTAINER, - command=cmd, - stderr=True, - stdin=False, - stdout=True, - tty=False, - _preload_content=False, - ) - stdout = "" - stderr = "" - while resp.is_open(): - resp.update(timeout=1) - if resp.peek_stdout(): - stdout_chunk = resp.read_stdout() - stdout += stdout_chunk - if resp.peek_stderr(): - stderr_chunk = resp.read_stderr() - stderr += stderr_chunk - return stdout + stderr - except Exception as err: - print(f"Could not execute stream: {err}") - - -@hello.command(context_settings={"ignore_unknown_options": True}) -@click.argument("pod", type=str) -@click.argument("method", type=str) -@click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments -def sh(pod: str, method: str, params: tuple[str, ...]): - """Run shell commands in a pod""" - print(_sh(pod, method, params)) - - if __name__ == "__main__": hello() diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index bff82818c..01b1afee5 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -7,8 +7,6 @@ import click from kubernetes.stream import stream -# When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission" -# tag for "lightning" nodes is stored in LIGHTNING_MISSION. from warnet.constants import LIGHTNING_MISSION, PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent from warnet.k8s import ( download, @@ -20,10 +18,6 @@ ) from warnet.process import run_command -# Tt is common for Warnet objects to have a "mission" tag to query them in the cluster. -# To make a "mission" tag for your plugin, declare it using the variable name MISSION. This will -# be read by the warnet log system and status system. -# This must match the pod's "mission" value in the plugin's associated helm file. MISSION = "simln" PRIMARY_CONTAINER = MISSION @@ -43,9 +37,6 @@ class PluginError(Exception): log.addHandler(console_handler) -# Warnet uses a python package called "click" to manage terminal interactions with the user. -# To use click, we must declare a click "group" by decorating a function named after the plugin. -# Using click makes it easy for users to interact with your plugin. @click.group() @click.pass_context def simln(ctx): @@ -55,10 +46,6 @@ def simln(ctx): ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) -# Each Warnet plugin must have an entrypoint function which takes a network_file_path and a -# hook_value. Possible hook values can be found in the HookValue enum. It also takes a namespace -# value and a variable number of arguments which is used by, for example, preNode and postNode to -# pass along node names. @simln.command() @click.argument("plugin_content", type=str) @click.argument("warnet_content", type=str) @@ -94,8 +81,6 @@ def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): _launch_activity(activity, ctx.obj.get(PLUGIN_DIR_TAG)) -# The group name is then used in decorators to create commands. These commands are -# available to users when they access your plugin from the command line. @simln.command() def list_pod_names(): """Get a list of SimLN pod names""" @@ -110,10 +95,6 @@ def download_results(pod_name: str): print(f"Downloaded results to: {dest}") -# When we want to use a command inside our plugin and also provide that command to the user, it -# helps to create a private function whose name starts with an underscore. We also make a public -# function with the same name except that we leave off the underscore, decorate it with the command -# decorator, and also provide an instructive doc string for the user. def _get_example_activity() -> list[dict]: pods = get_mission(LIGHTNING_MISSION) try: @@ -126,14 +107,12 @@ def _get_example_activity() -> list[dict]: return [{"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000}] -# Notice how the command that we make available to the user simply calls our internal command. @simln.command() def get_example_activity(): """Get an activity representing node 2 sending msat to node 3""" print(json.dumps(_get_example_activity())) -# Take note of how click expects us to explicitly declare command line arguments. @simln.command() @click.argument("activity", type=str) @click.pass_context diff --git a/test/simln_test.py b/test/simln_test.py index e6da58200..ac309faf4 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -15,7 +15,9 @@ class SimLNTest(TestBase): def __init__(self): super().__init__() - self.network_dir = Path(os.path.dirname(__file__)) / "data" / "plugins" / "ln" + self.network_dir = ( + Path(os.path.dirname(__file__)).parent / "resources" / "networks" / "hello" + ) self.plugins_dir = Path(os.path.dirname(__file__)).parent / "resources" / "plugins" self.simln_exec = "plugins/simln/plugin.py" From 3b575d63c6a611f0bf29482cdfe554826b4a25cd Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 13 Dec 2024 17:43:53 -0600 Subject: [PATCH 29/35] update documentation/examples; use PluginContent --- docs/plugins.md | 45 +++++++++++++++++++++---------- resources/plugins/hello/README.md | 8 +++--- resources/plugins/hello/plugin.py | 10 ++++--- resources/plugins/simln/plugin.py | 11 +++++--- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 13741644a..bce833864 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,55 +1,72 @@ # Plugins -Plugins allow users to extend Warnet. Plugin authors can import commands from Warnet and plugin users can run plugin commands from the command line or on each invocation of `warnet deploy`. +Plugins extend Warnet. Plugin authors can import commands from Warnet and interact with the kubernetes cluster, and plugin users can run plugins from the command line or from the `network.yaml` file. -## Activating plugins from 'network.yaml' +## Activating plugins from `network.yaml` -You can activate a plugin command by placing it in the `plugin` section at the bottom of each `network.yaml` file like so: +You can activate a plugin command by placing it in the `plugins` section at the bottom of each `network.yaml` file like so: + +````yaml +nodes: + <> + +plugins: # This marks the beginning of the plugin section + preDeploy: # This is a hook. This particular hook will call plugins before deploying anything else. + hello: # This is the name of the plugin. + entrypoint: "../plugins/hello" # Every plugin must specify a path to its entrypoint. + podName: "hello-pre-deploy" # Plugins can have their own particular configurations, such as how to name a pod. + helloTo: "preDeploy!" # This configuration tells the hello plugin who to say "hello" to. +```` + +## Many kinds of hooks +There are many hooks to the Warnet `deploy` command. The example below specifies them: ````yaml nodes: <> plugins: - preDeploy: + preDeploy: # Plugins will run before any other `deploy` code. hello: entrypoint: "../plugins/hello" podName: "hello-pre-deploy" helloTo: "preDeploy!" - postDeploy: + postDeploy: # Plugins will run after all the `deploy` code has run. simln: - entrypoint: "../../../resources/plugins/simln" + entrypoint: "../plugins/simln" activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' hello: entrypoint: "../plugins/hello" podName: "hello-post-deploy" helloTo: "postDeploy!" - preNode: + preNode: # Plugins will run before `deploy` launches a node (once per node). hello: entrypoint: "../plugins/hello" helloTo: "preNode!" - postNode: + postNode: # Plugins will run after `deploy` launches a node (once per node). hello: entrypoint: "../plugins/hello" helloTo: "postNode!" - preNetwork: + preNetwork: # Plugins will run before `deploy` launches the network (essentially between logging and when nodes are deployed) hello: entrypoint: "../plugins/hello" helloTo: "preNetwork!" podName: "hello-pre-network" - postNetwork: + postNetwork: # Plugins will run after the network deploy threads have been joined. hello: entrypoint: "../plugins/hello" helloTo: "postNetwork!" podName: "hello-post-network" ```` -Warnet will execute these plugin commands after each invocation of `warnet deploy`. +Warnet will execute these plugin commands during each invocation of `warnet deploy`. + + -## Example: SimLN +## A "hello" example -To get started with an example plugin, review the `README` of the `simln` plugin found in any initialized Warnet directory: +To get started with an example plugin, review the `README` of the `hello` plugin found in any initialized Warnet directory: 1. `warnet init` -2. `cd plugins/simln/` +2. `cd plugins/hello/` diff --git a/resources/plugins/hello/README.md b/resources/plugins/hello/README.md index 7d01ffad1..77bb5040f 100644 --- a/resources/plugins/hello/README.md +++ b/resources/plugins/hello/README.md @@ -1,10 +1,10 @@ # Hello Plugin ## Hello World! -*Hello* is an example plugin to demonstrate the features of Warnet's plugin architecture. +*Hello* is an example plugin to demonstrate the features of Warnet's plugin architecture. It uses each of the hooks available in the `warnet deploy` command (see the example below for details). ## Usage -In your python virtual environment with Warnet installed and started, create a new Warnet user folder (follow the prompts): +In your python virtual environment with Warnet installed and setup, create a new Warnet user folder (follow the prompts): `$ warnet new user_folder` @@ -18,14 +18,14 @@ While that is launching, take a look inside the `networks/hello/network.yaml` fi Also, take a look at the `plugins/hello/plugin.py` file to see how plugins work and to find out how to author your own plugin. -To view the pods that the *hello* network launched, run `kubectl get all -A` +Once `deploy` completes, view the pods of the *hello* network by invoking `kubectl get all -A`. To view the various "Hello World!" messages, run `kubectl logs pod/POD_NAME` ### A `network.yaml` example When you initialize a new Warnet network, Warnet will create a new `network.yaml` file. You can modify these files to fit your needs. -For example, example `network.yaml` file includes the *hello* plugin, lightning nodes and the *simln* plugin. +For example, the `network.yaml` file below includes the *hello* plugin, lightning nodes, and the *simln* plugin.
network.yaml diff --git a/resources/plugins/hello/plugin.py b/resources/plugins/hello/plugin.py index fe8fbada6..8b4a7271a 100755 --- a/resources/plugins/hello/plugin.py +++ b/resources/plugins/hello/plugin.py @@ -10,7 +10,7 @@ from warnet.constants import PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent from warnet.process import run_command -# Tt is common for Warnet objects to have a "mission" tag to query them in the cluster. +# Tt is common for Warnet objects to have a "mission" label to help query for them in the cluster. MISSION = "hello" PRIMARY_CONTAINER = MISSION @@ -32,7 +32,7 @@ class PluginError(Exception): log.propagate = True -# Plugins look like this in the network.yaml file: +# Plugins look like this in the `network.yaml` file: # # plugins: # hello: @@ -42,7 +42,7 @@ class PluginError(Exception): # "podName" and "helloTo" are essentially dictionary keys, and it helps to keep those keys in an # enum in order to prevent typos. class PluginContent(Enum): - POD_NAME = ("podName",) + POD_NAME = "podName" HELLO_TO = "helloTo" @@ -113,7 +113,9 @@ def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): def get_data(plugin_content: dict) -> Optional[dict]: data = { - key: plugin_content.get(key) for key in ("podName", "helloTo") if plugin_content.get(key) + key: plugin_content.get(key) + for key in (PluginContent.POD_NAME.value, PluginContent.HELLO_TO.value) + if plugin_content.get(key) } return data or None diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 01b1afee5..9d42de68e 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -2,6 +2,7 @@ import json import logging import time +from enum import Enum from pathlib import Path import click @@ -37,6 +38,10 @@ class PluginError(Exception): log.addHandler(console_handler) +class PluginContent(Enum): + ACTIVITY = "activity" + + @click.group() @click.pass_context def simln(ctx): @@ -75,7 +80,7 @@ def entrypoint(ctx, plugin_content: str, warnet_content: str): def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): """Called by entrypoint""" # write your plugin startup commands here - activity = plugin_content.get("activity") + activity = plugin_content.get(PluginContent.ACTIVITY.value) activity = json.loads(activity) print(activity) _launch_activity(activity, ctx.obj.get(PLUGIN_DIR_TAG)) @@ -114,7 +119,7 @@ def get_example_activity(): @simln.command() -@click.argument("activity", type=str) +@click.argument(PluginContent.ACTIVITY.value, type=str) @click.pass_context def launch_activity(ctx, activity: str): """Deploys a SimLN Activity which is a JSON list of objects""" @@ -164,7 +169,7 @@ def _generate_activity_json(activity: list[dict]) -> str: } nodes.append(node) - data = {"nodes": nodes, "activity": activity} + data = {"nodes": nodes, PluginContent.ACTIVITY.value: activity} return json.dumps(data, indent=2) From 33a93307f0df5d6cb8f48c7cc9a4bfb02ff2c874 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 18 Dec 2024 17:26:17 -0600 Subject: [PATCH 30/35] deploy: if no plugins, don't print Don't bother the user with messages from the plugin system if there are no plugin processes running. --- src/warnet/deploy.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 3b3d90f8a..c8eb89035 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -181,13 +181,15 @@ def run_plugins(directory, hook_value: HookValue, namespace, annex: Optional[dic ) sys.exit(1) - print(f"Starting {hook_value.value} plugins") + if processes: + print(f"Starting {hook_value.value} plugins") for process in processes: process.start() for process in processes: process.join() - print(f"Completed {hook_value.value} plugins") + if processes: + print(f"Completed {hook_value.value} plugins") def check_logging_required(directory: Path): From 27ac1f33feee6fc0606b8868ef4bf64fb1103709 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 18 Dec 2024 17:37:08 -0600 Subject: [PATCH 31/35] deploy: simplify process checking --- src/warnet/deploy.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index c8eb89035..d0b064d6f 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -183,12 +183,13 @@ def run_plugins(directory, hook_value: HookValue, namespace, annex: Optional[dic if processes: print(f"Starting {hook_value.value} plugins") - for process in processes: - process.start() - for process in processes: - process.join() - if processes: + for process in processes: + process.start() + + for process in processes: + process.join() + print(f"Completed {hook_value.value} plugins") From 39da86dcf606cff3adb5cd78a35b06d547f2e4a3 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 2 Jan 2025 18:05:56 -0600 Subject: [PATCH 32/35] incorporate many of zip's suggestions --- .github/workflows/test.yml | 2 +- resources/networks/hello/node-defaults.yaml | 2 +- resources/plugins/hello/plugin.py | 2 +- resources/plugins/simln/README.md | 4 ++-- .../plugins/simln/charts/simln/values.yaml | 4 ++-- src/warnet/k8s.py | 16 ---------------- src/warnet/process.py | 19 ------------------- 7 files changed, 7 insertions(+), 42 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40ccecc10..1d958604f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,9 +49,9 @@ jobs: - rpc_test.py - services_test.py - signet_test.py + - simln_test.py - scenarios_test.py - namespace_admin_test.py - - simln_test.py steps: - uses: actions/checkout@v4 - uses: azure/setup-helm@v4.2.0 diff --git a/resources/networks/hello/node-defaults.yaml b/resources/networks/hello/node-defaults.yaml index 884ad1343..24a00b5c8 100644 --- a/resources/networks/hello/node-defaults.yaml +++ b/resources/networks/hello/node-defaults.yaml @@ -5,4 +5,4 @@ image: lnd: defaultConfig: | - color=#000000 \ No newline at end of file + color=#000000 diff --git a/resources/plugins/hello/plugin.py b/resources/plugins/hello/plugin.py index 8b4a7271a..3253216e9 100755 --- a/resources/plugins/hello/plugin.py +++ b/resources/plugins/hello/plugin.py @@ -10,7 +10,7 @@ from warnet.constants import PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent from warnet.process import run_command -# Tt is common for Warnet objects to have a "mission" label to help query for them in the cluster. +# It is common for Warnet objects to have a "mission" label to help query them in the cluster. MISSION = "hello" PRIMARY_CONTAINER = MISSION diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index 59fd69229..f67da7563 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -25,7 +25,7 @@ Since SimLN already has access to those LND connection details, it means you can ### Launch activity definitions from the command line The SimLN plugin takes "activity" definitions like so: -`./simln/plugin.py launch-activiy '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'"''` +`./simln/plugin.py launch-activity '[{\"source\": \"tank-0003-ln\", \"destination\": \"tank-0005-ln\", \"interval_secs\": 1, \"amount_msat\": 2000}]'"''` ### Launch activity definitions from within `network.yaml` When you initialize a new Warnet network, Warnet will create a new `network.yaml` file. If your `network.yaml` file includes lightning nodes, then you can use SimLN to produce activity between those nodes like this: @@ -106,7 +106,7 @@ The SimLN plugin fetches a SimLN docker image from dockerhub. You can generate y 2. Follow the instructions to build a docker image as detailed int the SimLn repository. 3. Tag the resulting docker image: `docker tag IMAGEID YOURUSERNAME/sim-ln:VERSION` 4. Push the tagged image to you dockerhub account. -5Modify the `values.yaml` file in the plugin's chart to reflect your username and version number: +5. Modify the `values.yaml` file in the plugin's chart to reflect your username and version number: ```YAML repository: "YOURUSERNAME/sim-ln" tag: "VERSION" diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml index 4ffc40961..a1647a963 100644 --- a/resources/plugins/simln/charts/simln/values.yaml +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -1,7 +1,7 @@ name: "simln" image: - repository: "mplsgrant/sim-ln" - tag: "4d33f24" + repository: "bitcoindevproject/simln" + tag: "0.2.3" pullPolicy: IfNotPresent workingVolume: diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 620df8132..c12e4de1b 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -61,22 +61,6 @@ def get_pod(name: str, namespace: Optional[str] = None) -> V1Pod: return sclient.read_namespaced_pod(name=name, namespace=namespace) -def get_pods_with_label(label_selector: str, namespace: Optional[str] = None) -> list[V1Pod]: - """Get a list of pods by label. - Label example: "mission=lightning" - """ - namespace = get_default_namespace_or(namespace) - v1 = get_static_client() - - try: - pods = v1.list_namespaced_pod(namespace=namespace, label_selector=label_selector) - v1_pods = [pod for pod in pods.items] - return v1_pods - except client.exceptions.ApiException as e: - print(f"Error fetching pods: {e}") - return [] - - def get_mission(mission: str) -> list[V1Pod]: pods = get_pods() crew: list[V1Pod] = [] diff --git a/src/warnet/process.py b/src/warnet/process.py index 068436fc2..626124b71 100644 --- a/src/warnet/process.py +++ b/src/warnet/process.py @@ -1,5 +1,4 @@ import subprocess -from time import sleep def run_command(command: str) -> str: @@ -30,21 +29,3 @@ def stream_command(command: str) -> bool: if return_code != 0: raise Exception(message) return True - - -def wait_for_run(predicate, timeout=5 * 60, interval=5): - print(f"Waiting for predicate with timeout {timeout}s and interval {interval}s") - print(predicate) - while timeout > 0: - try: - if run_command(predicate): - return - except Exception: - pass - sleep(interval) - timeout -= interval - import inspect - - raise Exception( - f"Timed out waiting for Truth from predicate: {inspect.getsource(predicate).strip()}" - ) From 925f17a8a2d54add242f6d8245e640d8ebae9c2c Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 2 Jan 2025 18:06:19 -0600 Subject: [PATCH 33/35] allow random activity by omitting activity --- resources/plugins/simln/plugin.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 9d42de68e..1411ea645 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -4,6 +4,7 @@ import time from enum import Enum from pathlib import Path +from typing import Optional import click from kubernetes.stream import stream @@ -81,8 +82,9 @@ def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): """Called by entrypoint""" # write your plugin startup commands here activity = plugin_content.get(PluginContent.ACTIVITY.value) - activity = json.loads(activity) - print(activity) + if activity: + activity = json.loads(activity) + print(activity) _launch_activity(activity, ctx.obj.get(PLUGIN_DIR_TAG)) @@ -132,8 +134,8 @@ def launch_activity(ctx, activity: str): print(_launch_activity(parsed_activity, plugin_dir)) -def _launch_activity(activity: list[dict], plugin_dir: str) -> str: - """Launch a SimLN chart which includes the `activity`""" +def _launch_activity(activity: Optional[list[dict]], plugin_dir: str) -> str: + """Launch a SimLN chart which optionally includes the `activity`""" timestamp = int(time.time()) name = f"simln-{timestamp}" @@ -156,7 +158,7 @@ def _launch_activity(activity: list[dict], plugin_dir: str) -> str: raise PluginError(f"Could not write sim.json to the init container: {name}") -def _generate_activity_json(activity: list[dict]) -> str: +def _generate_activity_json(activity: Optional[list[dict]]) -> str: nodes = [] for i in get_mission(LIGHTNING_MISSION): @@ -169,7 +171,10 @@ def _generate_activity_json(activity: list[dict]) -> str: } nodes.append(node) - data = {"nodes": nodes, PluginContent.ACTIVITY.value: activity} + if activity: + data = {"nodes": nodes, PluginContent.ACTIVITY.value: activity} + else: + data = {"nodes": nodes} return json.dumps(data, indent=2) From e35178780fb202d3eb4b538ae0711070003649ba Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 3 Jan 2025 15:28:46 -0600 Subject: [PATCH 34/35] correct spelling in simln readme file --- resources/plugins/simln/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index f67da7563..f6b24ef92 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -103,9 +103,9 @@ plugins: The SimLN plugin fetches a SimLN docker image from dockerhub. You can generate your own docker image if you choose: 1. Clone SimLN: `git clone git@github.com:bitcoin-dev-project/sim-ln.git` -2. Follow the instructions to build a docker image as detailed int the SimLn repository. +2. Follow the instructions to build a docker image as detailed in the SimLN repository. 3. Tag the resulting docker image: `docker tag IMAGEID YOURUSERNAME/sim-ln:VERSION` -4. Push the tagged image to you dockerhub account. +4. Push the tagged image to your dockerhub account. 5. Modify the `values.yaml` file in the plugin's chart to reflect your username and version number: ```YAML repository: "YOURUSERNAME/sim-ln" From 7311bc2c558b6a4e6a9a48ccdb31b88d11e896a3 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 6 Jan 2025 09:30:28 -0600 Subject: [PATCH 35/35] set debug = False --- src/warnet/control.py | 4 +++- src/warnet/deploy.py | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/warnet/control.py b/src/warnet/control.py index d26614a48..99c497dac 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -261,7 +261,7 @@ def _run( source_dir, additional_args: tuple[str], namespace: Optional[str], -): +) -> str: namespace = get_default_namespace_or(namespace) scenario_path = Path(scenario_file).resolve() @@ -362,6 +362,8 @@ def filter(path): print("Deleting pod...") delete_pod(name, namespace=namespace) + return name + @click.command() @click.argument("pod_name", type=str, default="") diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index d0b064d6f..7230e9fa5 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -30,13 +30,14 @@ HookValue, WarnetContent, ) -from .control import _run +from .control import _logs, _run from .k8s import ( get_default_namespace, get_default_namespace_or, get_mission, get_namespaces_by_type, wait_for_ingress_controller, + wait_for_pod, wait_for_pod_ready, ) from .process import run_command, stream_command @@ -379,13 +380,15 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str p.join() if needs_ln_init: - _run( + name = _run( scenario_file=SCENARIOS_DIR / "ln_init.py", - debug=True, + debug=False, source_dir=SCENARIOS_DIR, additional_args=None, namespace=namespace, ) + wait_for_pod(name, namespace=namespace) + _logs(pod_name=name, follow=True, namespace=namespace) def deploy_single_node(node, directory: Path, debug: bool, namespace: str):