diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 890696754..1d958604f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,7 @@ jobs: - rpc_test.py - services_test.py - signet_test.py + - simln_test.py - scenarios_test.py - namespace_admin_test.py steps: 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/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..bce833864 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,72 @@ +# Plugins + +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` + +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: # Plugins will run before any other `deploy` code. + hello: + entrypoint: "../plugins/hello" + podName: "hello-pre-deploy" + helloTo: "preDeploy!" + postDeploy: # Plugins will run after all the `deploy` code has run. + 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: # Plugins will run before `deploy` launches a node (once per node). + hello: + entrypoint: "../plugins/hello" + helloTo: "preNode!" + postNode: # Plugins will run after `deploy` launches a node (once per node). + hello: + entrypoint: "../plugins/hello" + helloTo: "postNode!" + 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: # 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 during each invocation of `warnet deploy`. + + + +## A "hello" example + +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/hello/` + diff --git a/resources/networks/hello/network.yaml b/resources/networks/hello/network.yaml new file mode 100644 index 000000000..f5acf0a83 --- /dev/null +++ b/resources/networks/hello/network.yaml @@ -0,0 +1,87 @@ +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/resources/networks/hello/node-defaults.yaml b/resources/networks/hello/node-defaults.yaml new file mode 100644 index 000000000..24a00b5c8 --- /dev/null +++ b/resources/networks/hello/node-defaults.yaml @@ -0,0 +1,8 @@ +image: + repository: bitcoindevproject/bitcoin + pullPolicy: IfNotPresent + tag: "27.0" + +lnd: + defaultConfig: | + color=#000000 diff --git a/resources/plugins/__init__.py b/resources/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/resources/plugins/hello/README.md b/resources/plugins/hello/README.md new file mode 100644 index 000000000..77bb5040f --- /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. 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 setup, 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. + +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, the `network.yaml` file below 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/resources/plugins/hello/charts/hello/Chart.yaml b/resources/plugins/hello/charts/hello/Chart.yaml new file mode 100644 index 000000000..abd94467e --- /dev/null +++ b/resources/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/resources/plugins/hello/charts/hello/templates/pod.yaml b/resources/plugins/hello/charts/hello/templates/pod.yaml new file mode 100644 index 000000000..ba5319670 --- /dev/null +++ b/resources/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/resources/plugins/hello/charts/hello/values.yaml b/resources/plugins/hello/charts/hello/values.yaml new file mode 100644 index 000000000..302da3c15 --- /dev/null +++ b/resources/plugins/hello/charts/hello/values.yaml @@ -0,0 +1,2 @@ +podName: hello-pod +helloTo: "world" \ No newline at end of file diff --git a/resources/plugins/hello/plugin.py b/resources/plugins/hello/plugin.py new file mode 100755 index 000000000..3253216e9 --- /dev/null +++ b/resources/plugins/hello/plugin.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +import json +import logging +from enum import Enum +from pathlib import Path +from typing import Optional + +import click + +from warnet.constants import PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent +from warnet.process import run_command + +# It is common for Warnet objects to have a "mission" label to help query them in the cluster. +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 + + +# 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): + """Commands for the Hello plugin""" + ctx.ensure_object(dict) + plugin_dir = Path(__file__).resolve().parent + 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) +@click.pass_context +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" + + 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" + + warnet_content[WarnetContent.HOOK_VALUE.value] = HookValue(hook_value) + + _entrypoint(ctx, plugin_content, warnet_content) + + +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 + | HookValue.POST_NETWORK + | HookValue.PRE_DEPLOY + | HookValue.POST_DEPLOY + ): + 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 = 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 = 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_content: dict) -> Optional[dict]: + data = { + 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 + + +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 " + f"--set podName={podName} --set helloTo={helloTo}" + ) + log.info(command) + log.info(run_command(command)) + + +if __name__ == "__main__": + hello() diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md new file mode 100644 index 000000000..f6b24ef92 --- /dev/null +++ b/resources/plugins/simln/README.md @@ -0,0 +1,113 @@ +# 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-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: + +
+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: + postDeploy: + 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}]' +```` + +
+ + +## 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 in the SimLN repository. +3. Tag the resulting docker image: `docker tag IMAGEID YOURUSERNAME/sim-ln:VERSION` +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" + tag: "VERSION" +``` 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..3df6dd232 --- /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/templates/NOTES.txt b/resources/plugins/simln/charts/simln/templates/NOTES.txt new file mode 100644 index 000000000..74486845f --- /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..9688722b6 --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "mychart.fullname" . }}-data +data: + tls.cert: | + -----BEGIN CERTIFICATE----- + MIIB8TCCAZagAwIBAgIUJDsR6mmY+TaO9pCfjtotlbOkzJMwCgYIKoZIzj0EAwIw + MjEfMB0GA1UECgwWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAwwGd2Fy + bmV0MB4XDTI0MTExMTE2NTM1MFoXDTM0MTEwOTE2NTM1MFowMjEfMB0GA1UECgwW + bG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAwwGd2FybmV0MFkwEwYHKoZI + zj0CAQYIKoZIzj0DAQcDQgAEBVltIvaTlAQI/3FFatTqVflZuZdRJ0SmRMSJrFLP + tp0fxE7hmteSt6gjQriy90fP8j9OJXBNAjt915kLY4zVvqOBiTCBhjAOBgNVHQ8B + Af8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAd + BgNVHQ4EFgQU5d8QMrwhLgTkDjWA+eXZGz+dybUwLwYDVR0RBCgwJoIJbG9jYWxo + b3N0ggEqhwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMAoGCCqGSM49BAMCA0kAMEYC + IQDPofN0fEl5gTwCYhk3nZbjMqJhZ8BsSJ6K8XRhxr7zbwIhAPsgQCFOqUWg632O + NEO53OQ6CIqnpxSskjsFNH4ZBQOE + -----END CERTIFICATE----- + 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..69790c9eb --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -0,0 +1,50 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "mychart.fullname" . }} + labels: + app: {{ include "mychart.name" . }} + mission: {{ .Values.name }} +spec: + initContainers: + - name: "init" + image: "busybox" + command: + - "sh" + - "-c" + args: + - > + 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 }} + - 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..a1647a963 --- /dev/null +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -0,0 +1,13 @@ +name: "simln" +image: + repository: "bitcoindevproject/simln" + tag: "0.2.3" + pullPolicy: IfNotPresent + +workingVolume: + name: working-volume + mountPath: /working +configmapVolume: + name: configmap-volume + mountPath: /configmap + diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py new file mode 100755 index 000000000..1411ea645 --- /dev/null +++ b/resources/plugins/simln/plugin.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +import json +import logging +import time +from enum import Enum +from pathlib import Path +from typing import Optional + +import click +from kubernetes.stream import stream + +from warnet.constants import LIGHTNING_MISSION, PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent +from warnet.k8s import ( + download, + get_default_namespace, + get_mission, + get_static_client, + wait_for_init, + write_file_to_container, +) +from warnet.process import run_command + +MISSION = "simln" +PRIMARY_CONTAINER = MISSION + +PLUGIN_DIR_TAG = "plugin_dir" + + +class PluginError(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) + + +class PluginContent(Enum): + ACTIVITY = "activity" + + +@click.group() +@click.pass_context +def simln(ctx): + """Commands for the SimLN plugin""" + ctx.ensure_object(dict) + plugin_dir = Path(__file__).resolve().parent + ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) + + +@simln.command() +@click.argument("plugin_content", type=str) +@click.argument("warnet_content", type=str) +@click.pass_context +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" + + 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" + + warnet_content[WarnetContent.HOOK_VALUE.value] = HookValue(hook_value) + + _entrypoint(ctx, plugin_content, warnet_content) + + +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) + if activity: + activity = json.loads(activity) + print(activity) + _launch_activity(activity, ctx.obj.get(PLUGIN_DIR_TAG)) + + +@simln.command() +def list_pod_names(): + """Get a list of SimLN pod names""" + print([pod.metadata.name for pod in get_mission(MISSION)]) + + +@simln.command() +@click.argument("pod_name", type=str) +def download_results(pod_name: str): + """Download SimLN results to the current directory""" + dest = download(pod_name, source_path=Path("/working/results")) + print(f"Downloaded results to: {dest}") + + +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 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}] + + +@simln.command() +def get_example_activity(): + """Get an activity representing node 2 sending msat to node 3""" + print(json.dumps(_get_example_activity())) + + +@simln.command() +@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""" + 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: Optional[list[dict]], plugin_dir: str) -> str: + """Launch a SimLN chart which optionally includes the `activity`""" + timestamp = int(time.time()) + name = f"simln-{timestamp}" + + command = f"helm upgrade --install {timestamp} {plugin_dir}/charts/simln" + + 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", + "/working/sim.json", + activity_json, + namespace=get_default_namespace(), + quiet=True, + ): + return name + else: + raise PluginError(f"Could not write sim.json to the init container: {name}") + + +def _generate_activity_json(activity: Optional[list[dict]]) -> str: + 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) + + if activity: + data = {"nodes": nodes, PluginContent.ACTIVITY.value: activity} + else: + data = {"nodes": nodes} + + return json.dumps(data, indent=2) + + +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}") + + +@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 sh(pod: str, method: str, params: tuple[str, ...]): + """Run shell commands in a pod""" + print(_sh(pod, method, params)) + + +if __name__ == "__main__": + simln() diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 2c29448e8..017c9a749 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 @@ -20,10 +21,34 @@ TANK_MISSION = "tank" COMMANDER_MISSION = "commander" +LIGHTNING_MISSION = "lightning" BITCOINCORE_CONTAINER = "bitcoincore" COMMANDER_CONTAINER = "commander" + +class HookValue(Enum): + PRE_DEPLOY = "preDeploy" + POST_DEPLOY = "postDeploy" + PRE_NODE = "preNode" + POST_NODE = "postNode" + PRE_NETWORK = "preNetwork" + 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") @@ -32,6 +57,7 @@ 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" 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 455c00cac..7230e9fa5 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -1,3 +1,4 @@ +import json import subprocess import sys import tempfile @@ -22,19 +23,24 @@ NAMESPACES_CHART_LOCATION, NAMESPACES_FILE, NETWORK_FILE, + PLUGIN_ANNEX, SCENARIOS_DIR, WARGAMES_NAMESPACE_PREFIX, + AnnexMember, + 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 stream_command +from .process import run_command, stream_command HINT = "\nAre you trying to run a scenario? See `warnet run --help`" @@ -65,12 +71,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 +82,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: @@ -89,6 +90,8 @@ def _deploy(directory, debug, namespace, to_all_users): return if (directory / NETWORK_FILE).exists(): + run_plugins(directory, HookValue.PRE_DEPLOY, namespace) + processes = [] # Deploy logging CRD first to avoid synchronisation issues deploy_logging_crd(directory, debug) @@ -97,6 +100,8 @@ def _deploy(directory, debug, namespace, to_all_users): logging_process.start() processes.append(logging_process) + run_plugins(directory, HookValue.PRE_NETWORK, namespace) + network_process = Process(target=deploy_network, args=(directory, debug, namespace)) network_process.start() @@ -111,6 +116,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, namespace) + # 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() @@ -120,6 +127,8 @@ def _deploy(directory, debug, namespace, to_all_users): for p in processes: p.join() + run_plugins(directory, HookValue.POST_DEPLOY, namespace) + elif (directory / NAMESPACES_FILE).exists(): deploy_namespaces(directory) else: @@ -128,6 +137,63 @@ def _deploy(directory, debug, namespace, to_all_users): ) +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 + + 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}") + + processes = [] + + plugins_section = network_file.get("plugins", {}) + hook_section = plugins_section.get(hook_value.value, {}) + for plugin_name, plugin_content in hook_section.items(): + match (plugin_name, plugin_content): + case (str(), dict()): + try: + entrypoint_path = Path(plugin_content.get("entrypoint")) + except Exception as err: + raise SyntaxError("Each plugin must have an 'entrypoint'") from err + + 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_name} {plugin_content}" + ) + sys.exit(1) + + 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") + + def check_logging_required(directory: Path): # check if node-defaults has logging or metrics enabled default_file_path = directory / DEFAULTS_FILE @@ -142,7 +208,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 @@ -313,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): @@ -341,9 +410,21 @@ 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, 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, + annex={AnnexMember.NODE_NAME.value: node_name}, + ) + except Exception as e: click.echo(f"Error: {e}") return diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index d11214d04..c12e4de1b 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 @@ -302,7 +303,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() @@ -315,10 +316,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 @@ -372,7 +375,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() @@ -404,7 +407,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}") @@ -545,3 +549,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/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/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/src/warnet/status.py b/src/warnet/status.py index df62ed2df..c94f014cc 100644 --- a/src/warnet/status.py +++ b/src/warnet/status.py @@ -8,6 +8,7 @@ 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 @@ -86,7 +87,7 @@ def status(): def _get_tank_status(): - tanks = get_mission("tank") + tanks = get_mission(TANK_MISSION) return [ { "name": tank.metadata.name, @@ -98,7 +99,7 @@ def _get_tank_status(): def _get_deployed_scenarios(): - commanders = get_mission("commander") + commanders = get_mission(COMMANDER_MISSION) return [ { "name": c.metadata.name, diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index b125533c8..792861da2 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -51,4 +51,4 @@ nodes: addnode: - tank-0000 ln: - lnd: true \ No newline at end of file + lnd: true diff --git a/test/ln_test.py b/test/ln_test.py index 9a5b7c136..ee27b6256 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -1,12 +1,14 @@ #!/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.process import stream_command +from warnet.k8s import wait_for_pod +from warnet.process import run_command, stream_command class LNTest(TestBase): @@ -15,6 +17,8 @@ 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" + self.simln_exec = Path("simln/plugin.py") def run_test(self): try: @@ -22,6 +26,7 @@ def run_test(self): self.setup_network() self.test_channel_policies() self.test_payments() + self.run_simln() finally: self.cleanup() @@ -81,6 +86,38 @@ def get_and_pay(src, tgt): get_and_pay(8, 7) get_and_pay(4, 6) + def run_simln(self): + 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__": test = LNTest() diff --git a/test/simln_test.py b/test/simln_test.py new file mode 100755 index 000000000..ac309faf4 --- /dev/null +++ b/test/simln_test.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +import ast +import os +from functools import partial +from pathlib import Path +from typing import Optional + +import pexpect +from test_base import TestBase + +from warnet.k8s import download, wait_for_pod +from warnet.process import run_command + + +class SimLNTest(TestBase): + def __init__(self): + super().__init__() + 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" + + def run_test(self): + try: + os.chdir(self.tmpdir) + self.init_directory() + self.deploy_with_plugin() + self.copy_results() + self.assert_hello_plugin() + finally: + self.cleanup() + + 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() + + 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()) + + 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, Path("/working/results"), Path(".")) + self.wait_for_predicate(self.found_results_locally) + + 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.simln_exec} sh {pod} ls /working/results").strip() + self.log.info(f"Results file: {results_file}") + results = run_command( + f"{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.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}") + 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 + + def assert_hello_plugin(self): + self.log.info("Waiting for the 'hello' plugin pods.") + 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") + 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() + test.run_test()