From 63f22742a149307b6b9aaf7aa7f772873201154d Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 23 Aug 2024 10:51:22 +0200 Subject: [PATCH 01/72] lnd nodes running, connected to tank, rpc working --- .../charts/bitcoincore/templates/pod.yaml | 2 + resources/charts/lnd/Chart.yaml | 24 ++++ resources/charts/lnd/templates/_helpers.tpl | 57 +++++++++ resources/charts/lnd/templates/configmap.yaml | 11 ++ resources/charts/lnd/templates/pod.yaml | 73 +++++++++++ resources/charts/lnd/templates/service.yaml | 20 +++ resources/charts/lnd/values.yaml | 120 ++++++++++++++++++ src/warnet/constants.py | 1 + src/warnet/deploy.py | 46 ++++++- src/warnet/ln.py | 48 +++---- src/warnet/main.py | 2 + test/data/ln.graphml | 66 ---------- test/data/ln/network.yaml | 22 ++++ test/data/ln/node-defaults.yaml | 4 + test/ln_test.py | 92 +++++++------- test/test_base.py | 7 - 16 files changed, 456 insertions(+), 139 deletions(-) create mode 100644 resources/charts/lnd/Chart.yaml create mode 100644 resources/charts/lnd/templates/_helpers.tpl create mode 100644 resources/charts/lnd/templates/configmap.yaml create mode 100644 resources/charts/lnd/templates/pod.yaml create mode 100644 resources/charts/lnd/templates/service.yaml create mode 100644 resources/charts/lnd/values.yaml delete mode 100644 test/data/ln.graphml create mode 100644 test/data/ln/network.yaml create mode 100644 test/data/ln/node-defaults.yaml diff --git a/resources/charts/bitcoincore/templates/pod.yaml b/resources/charts/bitcoincore/templates/pod.yaml index d7076e6e9..5664ddda3 100644 --- a/resources/charts/bitcoincore/templates/pod.yaml +++ b/resources/charts/bitcoincore/templates/pod.yaml @@ -9,6 +9,8 @@ metadata: {{- end }} chain: {{ .Values.chain }} RPCPort: "{{ index .Values .Values.chain "RPCPort" }}" + ZMQTxPort: "{{ .Values.ZMQTxPort }}" + ZMQBlockPort: "{{ .Values.ZMQBlockPort }}" rpcpassword: {{ .Values.rpcpassword }} app: {{ include "bitcoincore.fullname" . }} {{- if .Values.collectLogs }} diff --git a/resources/charts/lnd/Chart.yaml b/resources/charts/lnd/Chart.yaml new file mode 100644 index 000000000..f5dffe372 --- /dev/null +++ b/resources/charts/lnd/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: lnd +description: A Helm chart for LND + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: 0.1.0 diff --git a/resources/charts/lnd/templates/_helpers.tpl b/resources/charts/lnd/templates/_helpers.tpl new file mode 100644 index 000000000..8e72f435a --- /dev/null +++ b/resources/charts/lnd/templates/_helpers.tpl @@ -0,0 +1,57 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "lnd.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "lnd.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "lnd.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "lnd.labels" -}} +helm.sh/chart: {{ include "lnd.chart" . }} +{{ include "lnd.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "lnd.selectorLabels" -}} +app.kubernetes.io/name: {{ include "lnd.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "lnd.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "lnd.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/resources/charts/lnd/templates/configmap.yaml b/resources/charts/lnd/templates/configmap.yaml new file mode 100644 index 000000000..0f3bfa18c --- /dev/null +++ b/resources/charts/lnd/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "lnd.fullname" . }} + labels: + {{- include "lnd.labels" . | nindent 4 }} +data: + lnd.conf: | + {{- .Values.baseConfig | nindent 4 }} + {{- .Values.defaultConfig | nindent 4 }} + {{- .Values.config | nindent 4 }} diff --git a/resources/charts/lnd/templates/pod.yaml b/resources/charts/lnd/templates/pod.yaml new file mode 100644 index 000000000..f411ddeed --- /dev/null +++ b/resources/charts/lnd/templates/pod.yaml @@ -0,0 +1,73 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "lnd.fullname" . }} + labels: + {{- include "lnd.labels" . | nindent 4 }} + {{- with .Values.extraLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + app: {{ include "lnd.fullname" . }} + {{- if .Values.collectLogs }} + collect_logs: "true" + {{- end }} +spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 4 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 4 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 8 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: rpc + containerPort: {{ .Values.RPCPort }} + protocol: TCP + - name: p2p + containerPort: {{ .Values.P2PPort }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 8 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 8 }} + resources: + {{- toYaml .Values.resources | nindent 8 }} + volumeMounts: + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + - mountPath: /root/.lnd/lnd.conf + name: config + subPath: lnd.conf + {{- if .Values.circuitBreaker }} + - name: circuitbreaker + image: pinheadmz/circuitbreaker:278737d + imagePullPolicy: IfNotPresent + {{- end}} + volumes: + {{- with .Values.volumes }} + {{- toYaml . | nindent 4 }} + {{- end }} + - configMap: + name: {{ include "lnd.fullname" . }} + name: config + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 4 }} + {{- end }} diff --git a/resources/charts/lnd/templates/service.yaml b/resources/charts/lnd/templates/service.yaml new file mode 100644 index 000000000..6b2bc404e --- /dev/null +++ b/resources/charts/lnd/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "lnd.fullname" . }} + labels: + {{- include "lnd.labels" . | nindent 4 }} + app: {{ include "lnd.fullname" . }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.RPCPort }} + targetPort: rpc + protocol: TCP + name: rpc + - port: {{ .Values.P2PPort }} + targetPort: p2p + protocol: TCP + name: p2p + selector: + {{- include "lnd.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/lnd/values.yaml b/resources/charts/lnd/values.yaml new file mode 100644 index 000000000..644dce35a --- /dev/null +++ b/resources/charts/lnd/values.yaml @@ -0,0 +1,120 @@ +# Default values for lnd. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +namespace: warnet + +image: + repository: lightninglabs/lnd + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "v0.18.3-beta" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +podLabels: + app: "warnet" + mission: "lightning" + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + +RPCPort: 10009 +P2PPort: 9735 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + +livenessProbe: + exec: + command: + - pidof + - lnd + failureThreshold: 3 + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 +readinessProbe: + failureThreshold: 1 + periodSeconds: 1 + successThreshold: 1 + tcpSocket: + port: 10009 + timeoutSeconds: 1 + + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +baseConfig: | + noseedbackup=true + norest=true + debuglevel=debug + accept-keysend=true + bitcoin.active=true + bitcoin.node=bitcoind + maxpendingchannels=64 + trickledelay=1 + rpclisten=0.0.0.0:10009 + # zmq* and bitcoind.{rpcuser, rpcpass} are set in warnet code + +config: "" + +defaultConfig: "" + +extraLabels: {} diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 46f33a3fe..104ff64b0 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -39,6 +39,7 @@ # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) +LND_CHART_LOCATION = str(CHARTS_DIR.joinpath("lnd")) FORK_OBSERVER_CHART = str(CHARTS_DIR.joinpath("fork-observer")) COMMANDER_CHART = str(CHARTS_DIR.joinpath("commander")) NAMESPACES_CHART_LOCATION = CHARTS_DIR.joinpath("namespaces") diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index d9b5a45b5..bc657d0bf 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -15,6 +15,7 @@ FORK_OBSERVER_CHART, HELM_COMMAND, INGRESS_HELM_COMMANDS, + LND_CHART_LOCATION, LOGGING_HELM_COMMANDS, LOGGING_NAMESPACE, NAMESPACES_CHART_LOCATION, @@ -27,6 +28,7 @@ get_default_namespace_or, get_mission, get_namespaces_by_type, + get_pod, wait_for_ingress_controller, wait_for_pod_ready, ) @@ -243,7 +245,7 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str try: temp_override_file_path = "" node_name = node.get("name") - node_config_override = {k: v for k, v in node.items() if k != "name"} + node_config_override = {k: v for k, v in node.items() if k != "name" and k != "lnd"} cmd = f"{HELM_COMMAND} {node_name} {BITCOIN_CHART_LOCATION} --namespace {namespace} -f {defaults_file_path}" if debug: @@ -260,6 +262,11 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str if not stream_command(cmd): click.echo(f"Failed to run Helm command: {cmd}") return + + lnd = node.get("lnd") + if lnd: + deploy_lnd(node, namespace) + except Exception as e: click.echo(f"Error: {e}") return @@ -268,6 +275,43 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str Path(temp_override_file_path).unlink() +def deploy_lnd(node: object, namespace: str): + node_name = node.get("name") + lnd_name = f"{node_name}-lnd" + + tank = get_pod(node_name, namespace) + + # A little harsh but for now let's make sure this always works + assert tank + + extra_conf = { + "extraLabels": {"chain": tank.metadata.labels["chain"]}, + "config": ("\n").join( + [ + f"bitcoin.{tank.metadata.labels['chain']}=1", + "bitcoind.rpcuser=user", + f"bitcoind.rpcpass={tank.metadata.labels['rpcpassword']}", + f"bitcoind.rpchost={node_name}:{int(tank.metadata.labels['RPCPort'])}", + f"bitcoind.zmqpubrawblock=tcp://{node_name}:{int(tank.metadata.labels['ZMQBlockPort'])}", + f"bitcoind.zmqpubrawtx=tcp://{node_name}:{int(tank.metadata.labels['ZMQTxPort'])}", + f"alias={lnd_name}", + f"externalhosts={lnd_name}", + f"tlsextradomain={lnd_name}", + ] + ), + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as temp_file: + yaml.dump(extra_conf, temp_file) + extra_conf_file_path = Path(temp_file.name) + + cmd = f"{HELM_COMMAND} {lnd_name} {LND_CHART_LOCATION} --namespace {namespace} -f {extra_conf_file_path}" + if not stream_command(cmd): + print(f"Failed to run Helm command: {cmd}") + return + Path(extra_conf_file_path).unlink() + + def deploy_namespaces(directory: Path): namespaces_file_path = directory / NAMESPACES_FILE defaults_file_path = directory / DEFAULTS_NAMESPACE_FILE diff --git a/src/warnet/ln.py b/src/warnet/ln.py index ade55759e..72303dcee 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -1,6 +1,9 @@ +import json + import click -from .rpc import rpc_call +from .k8s import get_pod +from .process import run_command @click.group(name="ln") @@ -9,31 +12,32 @@ def ln(): @ln.command(context_settings={"ignore_unknown_options": True}) -@click.argument("node", type=int) -@click.argument("command", type=str, required=True, nargs=-1) -@click.option("--network", default="warnet", show_default=True, type=str) -def rpc(node: int, command: tuple, network: str): +@click.argument("pod", type=str) +@click.argument("command", type=str, required=True) +def rpc(pod: str, command: str): """ - Call lightning cli rpc on in [network] + Call lightning cli rpc on """ - print( - rpc_call( - "tank_lncli", - {"network": network, "node": node, "command": command}, - ) - ) + print(_rpc(pod, command)) + + +def _rpc(pod_name: str, command: str): + # TODO: when we add back cln we'll need to describe the pod, + # get a label with implementation type and then adjust command + pod = get_pod(pod_name) + chain = pod.metadata.labels["chain"] + cmd = f"kubectl exec {pod_name} -- lncli --network {chain} {command}" + return run_command(cmd) @ln.command(context_settings={"ignore_unknown_options": True}) -@click.argument("node", type=int) -@click.option("--network", default="warnet", show_default=True, type=str) -def pubkey(node: int, network: str): +@click.argument("pod", type=str) +def pubkey( + pod: str, +): """ - Get lightning node pub key on in [network] + Get lightning node pub key from """ - print( - rpc_call( - "tank_ln_pub_key", - {"network": network, "node": node}, - ) - ) + # TODO: again here, cln will need a different command + info = _rpc(pod, "getinfo") + print(json.loads(info)["identity_pubkey"]) diff --git a/src/warnet/main.py b/src/warnet/main.py index 76893575c..ffc24cf78 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -7,6 +7,7 @@ from .deploy import deploy from .graph import create, graph from .image import image +from .ln import ln from .project import init, new, setup from .status import status from .users import auth @@ -27,6 +28,7 @@ def cli(): cli.add_command(image) cli.add_command(init) cli.add_command(logs) +cli.add_command(ln) cli.add_command(new) cli.add_command(run) cli.add_command(setup) diff --git a/test/data/ln.graphml b/test/data/ln.graphml deleted file mode 100644 index efd0c359f..000000000 --- a/test/data/ln.graphml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - simln - - 27.0 - lnd - lightninglabs/lnd:v0.17.5-beta - true - - - 27.0 - lnd - pinheadmz/circuitbreaker:278737d - true - - - 27.0 - lnd - pinheadmz/circuitbreaker:278737d - --bitcoin.timelockdelta=33 - - - 27.0 - cln - --cltv-delta=33 - - - 27.0 - - - - - - - - - - --local_amt=100000 - --base_fee_msat=2200 --fee_rate_ppm=13 --time_lock_delta=20 - - - --local_amt=100000 --push_amt=50000 - --base_fee_msat=5500 --fee_rate_ppm=3 --time_lock_delta=40 - - - amount=100000 push_msat=50000000 - feebase=5500 feeppm=3 - - - \ No newline at end of file diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml new file mode 100644 index 000000000..c57b3b6b4 --- /dev/null +++ b/test/data/ln/network.yaml @@ -0,0 +1,22 @@ +nodes: + - name: tank-0000 + connect: + - tank-0001 + lnd: true + - name: tank-0001 + connect: + - tank-0002 + lnd: true + - name: tank-0002 + connect: + - tank-0000 + - tank-0003 + lnd: true + - name: tank-0003 + connect: + - tank-0004 + lnd: true + - name: tank-0004 + connect: + - tank-0000 + lnd: true \ No newline at end of file diff --git a/test/data/ln/node-defaults.yaml b/test/data/ln/node-defaults.yaml new file mode 100644 index 000000000..7e021cad1 --- /dev/null +++ b/test/data/ln/node-defaults.yaml @@ -0,0 +1,4 @@ +image: + repository: bitcoindevproject/bitcoin + pullPolicy: IfNotPresent + tag: "27.0" diff --git a/test/ln_test.py b/test/ln_test.py index 576846b6b..8efe0b385 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -6,53 +6,51 @@ from test_base import TestBase -from warnet.services import ServiceType +from warnet.cli.process import run_command class LNTest(TestBase): def __init__(self): super().__init__() - self.graph_file_path = Path(os.path.dirname(__file__)) / "data" / "ln.graphml" + self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" def run_test(self): - self.start_server() try: self.setup_network() self.run_ln_init_scenario() self.test_channel_policies() self.test_ln_payment_0_to_2() self.test_ln_payment_2_to_0() - self.test_simln() + # self.test_simln() finally: self.cleanup() def setup_network(self): self.log.info("Setting up network") - self.log.info(self.warnet(f"network start {self.graph_file_path}")) + self.log.info(self.warcli(f"network deploy {self.network_dir}")) self.wait_for_all_tanks_status(target="running") self.wait_for_all_edges() - def get_cb_forwards(self, index): - cmd = "wget -q -O - 127.0.0.1:9235/api/forwarding_history" - res = self.wait_for_rpc( - "exec_run", [index, ServiceType.CIRCUITBREAKER.value, cmd, self.network_name] - ) + def get_cb_forwards(self, pod: str): + cmd = f"kubectl exec {pod} -- wget -q -O - 127.0.0.1:9235/api/forwarding_history" + res = run_command(cmd) return json.loads(res) def run_ln_init_scenario(self): self.log.info("Running LN Init scenario") - self.warnet("bitcoin rpc 0 getblockcount") + self.warnet("bitcoin rpc tank-0000 getblockcount") self.warnet("scenarios run ln_init") self.wait_for_all_scenarios() - scenario_return_code = self.get_scenario_return_code("ln_init") - if scenario_return_code != 0: - raise Exception("LN Init scenario failed") def test_channel_policies(self): self.log.info("Ensuring node-level channel policy settings") - node2pub, node2host = json.loads(self.warnet("ln rpc 2 getinfo"))["uris"][0].split("@") - chan_id = json.loads(self.warnet("ln rpc 2 listchannels"))["channels"][0]["chan_id"] - chan = json.loads(self.warnet(f"ln rpc 2 getchaninfo {chan_id}")) + node2pub, node2host = json.loads(self.warnet("ln rpc tank-0002-ln getinfo"))["uris"][ + 0 + ].split("@") + chan_id = json.loads(self.warnet("ln rpc tank-0002-ln listchannels"))["channels"][0][ + "chan_id" + ] + chan = json.loads(self.warnet(f"ln rpc tank-0002-ln getchaninfo {chan_id}")) # node_1 or node_2 is tank 2 with its non-default --bitcoin.timelockdelta=33 if chan["node1_policy"]["time_lock_delta"] != 33: @@ -61,65 +59,73 @@ def test_channel_policies(self): ), "Expected time_lock_delta to be 33" self.log.info("Ensuring no circuit breaker forwards yet") - assert len(self.get_cb_forwards(1)["forwards"]) == 0, "Expected no circuit breaker forwards" + assert ( + len(self.get_cb_forwards("tank-0001-ln")["forwards"]) == 0 + ), "Expected no circuit breaker forwards" def test_ln_payment_0_to_2(self): self.log.info("Test LN payment from 0 -> 2") - inv = json.loads(self.warnet("ln rpc 2 addinvoice --amt=2000"))["payment_request"] - self.log.info(f"Got invoice from node 2: {inv}") + inv = json.loads(self.warnet("ln rpc tank-0002-ln addinvoice --amt=2000"))[ + "payment_request" + ] + self.log.info(f"Got invoice from node tank-0002-ln: {inv}") self.log.info("Paying invoice from node 0...") - self.log.info(self.warnet(f"ln rpc 0 payinvoice -f {inv}")) + self.log.info(self.warnet(f"ln rpc tank-0000-ln payinvoice -f {inv}")) self.wait_for_predicate(self.check_invoice_settled) self.log.info("Ensuring channel-level channel policy settings: source") - payment = json.loads(self.warnet("ln rpc 0 listpayments"))["payments"][0] + payment = json.loads(self.warnet("ln rpc tank-0000-ln listpayments"))["payments"][0] assert ( payment["fee_msat"] == "5506" ), f"Expected fee_msat to be 5506, got {payment['fee_msat']}" self.log.info("Ensuring circuit breaker tracked payment") - assert len(self.get_cb_forwards(1)["forwards"]) == 1, "Expected one circuit breaker forward" + assert ( + len(self.get_cb_forwards("tank-0001-ln")["forwards"]) == 1 + ), "Expected one circuit breaker forward" def test_ln_payment_2_to_0(self): self.log.info("Test LN payment from 2 -> 0") - inv = json.loads(self.warnet("ln rpc 0 addinvoice --amt=1000"))["payment_request"] + inv = json.loads(self.warnet("ln rpc tank-0000-ln addinvoice --amt=1000"))[ + "payment_request" + ] self.log.info(f"Got invoice from node 0: {inv}") self.log.info("Paying invoice from node 2...") - self.log.info(self.warnet(f"ln rpc 2 payinvoice -f {inv}")) + self.log.info(self.warnet(f"ln rpc tank-0002-ln payinvoice -f {inv}")) - self.wait_for_predicate(lambda: self.check_invoices(0) == 1) + self.wait_for_predicate(lambda: self.check_invoices("tank-0000-ln") == 1) self.log.info("Ensuring channel-level channel policy settings: target") - payment = json.loads(self.warnet("ln rpc 2 listpayments"))["payments"][0] + payment = json.loads(self.warnet("ln rpc tank-0002-ln listpayments"))["payments"][0] assert ( payment["fee_msat"] == "2213" ), f"Expected fee_msat to be 2213, got {payment['fee_msat']}" - def test_simln(self): - self.log.info("Engaging simln") - node2pub, _ = json.loads(self.warnet("ln rpc 2 getinfo"))["uris"][0].split("@") - activity = [ - {"source": "ln-0", "destination": node2pub, "interval_secs": 1, "amount_msat": 2000} - ] - self.warnet( - f"network export --exclude=[1] --activity={json.dumps(activity).replace(' ', '')}" - ) - self.wait_for_predicate(lambda: self.check_invoices(2) > 1) - assert self.check_invoices(0) == 1, "Expected one invoice for node 0" - assert self.check_invoices(1) == 0, "Expected no invoices for node 1" + # def test_simln(self): + # self.log.info("Engaging simln") + # node2pub, _ = json.loads(self.warnet("ln rpc 2 getinfo"))["uris"][0].split("@") + # activity = [ + # {"source": "ln-0", "destination": node2pub, "interval_secs": 1, "amount_msat": 2000} + # ] + # self.warnet( + # f"network export --exclude=[1] --activity={json.dumps(activity).replace(' ', '')}" + # ) + # self.wait_for_predicate(lambda: self.check_invoices(2) > 1) + # assert self.check_invoices(0) == 1, "Expected one invoice for node 0" + # assert self.check_invoices(1) == 0, "Expected no invoices for node 1" def check_invoice_settled(self): - invs = json.loads(self.warnet("ln rpc 2 listinvoices"))["invoices"] + invs = json.loads(self.warnet("ln rpc tank-0002-ln listinvoices"))["invoices"] if len(invs) > 0 and invs[0]["state"] == "SETTLED": self.log.info("Invoice settled") return True return False - def check_invoices(self, index): - invs = json.loads(self.warnet(f"ln rpc {index} listinvoices"))["invoices"] + def check_invoices(self, pod: str): + invs = json.loads(self.warnet(f"ln rpc {pod} listinvoices"))["invoices"] settled = sum(1 for inv in invs if inv["state"] == "SETTLED") - self.log.debug(f"Node {index} has {settled} settled invoices") + self.log.debug(f"lnd {pod} has {settled} settled invoices") return settled diff --git a/test/test_base.py b/test/test_base.py index 2b024da64..51d5935d6 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -139,13 +139,6 @@ def check_scenarios(): self.wait_for_predicate(check_scenarios) - def get_scenario_return_code(self, scenario_name): - scns = self.rpc("scenarios_list_running") - scns = [scn for scn in scns if scn["cmd"].strip() == scenario_name] - if len(scns) == 0: - raise Exception(f"Scenario {scenario_name} not found in running scenarios") - return scns[0]["return_code"] - def assert_equal(thing1, thing2, *args): if thing1 != thing2 or any(thing1 != arg for arg in args): From b4f244fa5da3b24c6aa5ac081f167ec963c94935 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Wed, 6 Nov 2024 12:51:05 -0500 Subject: [PATCH 02/72] test: ensure ln channels and payments with rpc commands --- .github/workflows/test.yml | 1 + src/warnet/ln.py | 33 ++++++++--- test/data/ln/network.yaml | 15 +---- test/ln_basic_test.py | 109 +++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 19 deletions(-) create mode 100755 test/ln_basic_test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acac68266..5e199ed4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,7 @@ jobs: - dag_connection_test.py - graph_test.py - logging_test.py + - ln_basic_test.py - rpc_test.py - services_test.py - signet_test.py diff --git a/src/warnet/ln.py b/src/warnet/ln.py index 72303dcee..22ab86630 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -1,8 +1,9 @@ import json +from typing import Optional import click -from .k8s import get_pod +from .k8s import get_default_namespace_or, get_pod from .process import run_command @@ -13,24 +14,27 @@ def ln(): @ln.command(context_settings={"ignore_unknown_options": True}) @click.argument("pod", type=str) -@click.argument("command", type=str, required=True) -def rpc(pod: str, command: str): +@click.argument("method", type=str) +@click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments +@click.option("--namespace", default=None, show_default=True) +def rpc(pod: str, method: str, params: str, namespace: Optional[str]): """ Call lightning cli rpc on """ - print(_rpc(pod, command)) + print(_rpc(pod, method, params, namespace)) -def _rpc(pod_name: str, command: str): +def _rpc(pod_name: str, method: str, params: str = "", namespace: Optional[str] = None): # TODO: when we add back cln we'll need to describe the pod, # get a label with implementation type and then adjust command pod = get_pod(pod_name) + namespace = get_default_namespace_or(namespace) chain = pod.metadata.labels["chain"] - cmd = f"kubectl exec {pod_name} -- lncli --network {chain} {command}" + cmd = f"kubectl -n {namespace} exec {pod_name} -- lncli --network {chain} {method} {' '.join(map(str, params))}" return run_command(cmd) -@ln.command(context_settings={"ignore_unknown_options": True}) +@ln.command() @click.argument("pod", type=str) def pubkey( pod: str, @@ -41,3 +45,18 @@ def pubkey( # TODO: again here, cln will need a different command info = _rpc(pod, "getinfo") print(json.loads(info)["identity_pubkey"]) + + +@ln.command() +@click.argument("pod", type=str) +def host( + pod: str, +): + """ + Get lightning node host from + """ + # TODO: again here, cln will need a different command + info = _rpc(pod, "getinfo") + uris = json.loads(info)["uris"] + if uris and len(uris) >= 0: + print(uris[0].split("@")[1]) diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index c57b3b6b4..562c4a1d0 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -1,22 +1,13 @@ nodes: - name: tank-0000 - connect: + addnode: - tank-0001 lnd: true - name: tank-0001 - connect: + addnode: - tank-0002 lnd: true - name: tank-0002 - connect: + addnode: - tank-0000 - - tank-0003 lnd: true - - name: tank-0003 - connect: - - tank-0004 - lnd: true - - name: tank-0004 - connect: - - tank-0000 - lnd: true \ No newline at end of file diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py new file mode 100755 index 000000000..b9923d84c --- /dev/null +++ b/test/ln_basic_test.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path +from time import sleep + +from test_base import TestBase + + +class LNBasicTest(TestBase): + def __init__(self): + super().__init__() + self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" + self.miner_addr = "" + + def run_test(self): + try: + self.setup_network() + self.fund_wallets() + self.manual_open_channels() + self.wait_for_gossip_sync() + self.pay_invoice() + 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 fund_wallets(self): + self.warnet("bitcoin rpc tank-0000 createwallet miner") + self.warnet("bitcoin rpc tank-0000 -generate 110") + self.wait_for_predicate( + lambda: int(self.warnet("bitcoin rpc tank-0000 getblockcount")) > 100 + ) + + addrs = [] + for lnd in ["tank-0000-lnd", "tank-0001-lnd", "tank-0002-lnd"]: + addrs.append(json.loads(self.warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"]) + + self.warnet( + "bitcoin rpc tank-0000 sendmany '' '{" + + f'"{addrs[0]}":10,"{addrs[1]}":10,"{addrs[2]}":10' + + "}'" + ) + self.warnet("bitcoin rpc tank-0000 -generate 1") + + def manual_open_channels(self): + # 0 -> 1 -> 2 + pk1 = self.warnet("ln pubkey tank-0001-lnd") + pk2 = self.warnet("ln pubkey tank-0002-lnd") + + host1 = None + host2 = None + + while not host1 or not host2: + if not host1: + host1 = self.warnet("ln host tank-0001-lnd") + if not host2: + host2 = self.warnet("ln host tank-0002-lnd") + sleep(1) + + print( + self.warnet( + f"ln rpc tank-0000-lnd openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" + ) + ) + print( + self.warnet( + f"ln rpc tank-0001-lnd openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" + ) + ) + + def wait_for_two_txs(): + return json.loads(self.warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 2 + + self.wait_for_predicate(wait_for_two_txs) + + self.warnet("bitcoin rpc tank-0000 -generate 10") + + def wait_for_gossip_sync(self): + chs0 = [] + chs1 = [] + chs2 = [] + + while len(chs0) != 2 or len(chs1) != 2 or len(chs2) != 2: + if len(chs0) != 2: + chs0 = json.loads(self.warnet("ln rpc tank-0000-lnd describegraph"))["edges"] + if len(chs1) != 2: + chs1 = json.loads(self.warnet("ln rpc tank-0001-lnd describegraph"))["edges"] + if len(chs2) != 2: + chs2 = json.loads(self.warnet("ln rpc tank-0002-lnd describegraph"))["edges"] + sleep(1) + + def pay_invoice(self): + inv = json.loads(self.warnet("ln rpc tank-0002-lnd addinvoice --amt 1000")) + print(inv) + print(self.warnet(f"ln rpc tank-0000-lnd payinvoice -f {inv['payment_request']}")) + + def wait_for_success(): + return json.loads(self.warnet("ln rpc tank-0002-lnd channelbalance"))["balance"] == 1000 + self.wait_for_predicate(wait_for_success) + + +if __name__ == "__main__": + test = LNBasicTest() + test.run_test() From b637bd3e0bda4f9c53a20de666cf62fdd5c2db27 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Wed, 6 Nov 2024 13:10:29 -0500 Subject: [PATCH 03/72] test: wait for ln nodes to be ready --- test/ln_basic_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index b9923d84c..473d9b02c 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -29,6 +29,18 @@ def setup_network(self): self.log.info(self.warnet(f"deploy {self.network_dir}")) self.wait_for_all_tanks_status(target="running") + def wait_for_all_ln_rpc(): + nodes = ["tank-0000-lnd", "tank-0001-lnd", "tank-0002-lnd"] + for node in nodes: + try: + self.warnet(f"ln rpc {node} getinfo") + except Exception as e: + print(f"LN node {node} not ready for rpc yet: {e}") + return False + return True + + self.wait_for_predicate(wait_for_all_ln_rpc) + def fund_wallets(self): self.warnet("bitcoin rpc tank-0000 createwallet miner") self.warnet("bitcoin rpc tank-0000 -generate 110") From e9dfabe86941a4f2ba1e2769d05f948fd07fc5b1 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Wed, 6 Nov 2024 16:32:25 -0500 Subject: [PATCH 04/72] BREAKING REFACTOR: move some values to global, add lnd as subchart --- resources/charts/bitcoincore/Chart.yaml | 5 +++ .../{ => bitcoincore/charts}/lnd/Chart.yaml | 1 + .../charts}/lnd/templates/_helpers.tpl | 25 ++++++++++- .../charts/lnd/templates/configmap.yaml | 19 ++++++++ .../charts}/lnd/templates/pod.yaml | 1 + .../charts}/lnd/templates/service.yaml | 0 .../{ => bitcoincore/charts}/lnd/values.yaml | 3 +- .../charts/bitcoincore/templates/_helpers.tpl | 2 +- .../bitcoincore/templates/configmap.yaml | 10 ++--- .../charts/bitcoincore/templates/pod.yaml | 28 ++++++------ .../charts/bitcoincore/templates/service.yaml | 8 ++-- resources/charts/bitcoincore/values.yaml | 31 ++++++------- resources/charts/lnd/templates/configmap.yaml | 11 ----- src/warnet/deploy.py | 43 ------------------- test/data/ln/network.yaml | 11 +++-- test/data/signet/node-defaults.yaml | 3 +- test/ln_basic_test.py | 32 +++++++------- 17 files changed, 117 insertions(+), 116 deletions(-) rename resources/charts/{ => bitcoincore/charts}/lnd/Chart.yaml (99%) rename resources/charts/{ => bitcoincore/charts}/lnd/templates/_helpers.tpl (71%) create mode 100644 resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml rename resources/charts/{ => bitcoincore/charts}/lnd/templates/pod.yaml (98%) rename resources/charts/{ => bitcoincore/charts}/lnd/templates/service.yaml (100%) rename resources/charts/{ => bitcoincore/charts}/lnd/values.yaml (96%) delete mode 100644 resources/charts/lnd/templates/configmap.yaml diff --git a/resources/charts/bitcoincore/Chart.yaml b/resources/charts/bitcoincore/Chart.yaml index f99064472..4feb6e32e 100644 --- a/resources/charts/bitcoincore/Chart.yaml +++ b/resources/charts/bitcoincore/Chart.yaml @@ -2,6 +2,11 @@ apiVersion: v2 name: bitcoincore description: A Helm chart for Bitcoin Core +dependencies: + - name: lnd + version: 0.1.0 + condition: ln.lnd + # A chart can be either an 'application' or a 'library' chart. # # Application charts are a collection of templates that can be packaged into versioned archives diff --git a/resources/charts/lnd/Chart.yaml b/resources/charts/bitcoincore/charts/lnd/Chart.yaml similarity index 99% rename from resources/charts/lnd/Chart.yaml rename to resources/charts/bitcoincore/charts/lnd/Chart.yaml index f5dffe372..b77eb714a 100644 --- a/resources/charts/lnd/Chart.yaml +++ b/resources/charts/bitcoincore/charts/lnd/Chart.yaml @@ -1,5 +1,6 @@ apiVersion: v2 name: lnd + description: A Helm chart for LND # A chart can be either an 'application' or a 'library' chart. diff --git a/resources/charts/lnd/templates/_helpers.tpl b/resources/charts/bitcoincore/charts/lnd/templates/_helpers.tpl similarity index 71% rename from resources/charts/lnd/templates/_helpers.tpl rename to resources/charts/bitcoincore/charts/lnd/templates/_helpers.tpl index 8e72f435a..de7c0c156 100644 --- a/resources/charts/lnd/templates/_helpers.tpl +++ b/resources/charts/bitcoincore/charts/lnd/templates/_helpers.tpl @@ -1,8 +1,29 @@ +{{/* +Expand the name of the PARENT chart. +*/}} +{{- define "bitcoincore.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified PARENT app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "bitcoincore.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + + {{/* Expand the name of the chart. */}} {{- define "lnd.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}-ln {{- end }} {{/* @@ -14,7 +35,7 @@ If release name contains chart name it will be used as a full name. {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} -{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }}-ln {{- end }} {{- end }} diff --git a/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml b/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml new file mode 100644 index 000000000..f1477c1d6 --- /dev/null +++ b/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "lnd.fullname" . }} + labels: + {{- include "lnd.labels" . | nindent 4 }} +data: + lnd.conf: | + {{- .Values.baseConfig | nindent 4 }} + {{- .Values.defaultConfig | nindent 4 }} + {{- .Values.config | nindent 4 }} + bitcoin.{{ .Values.global.chain }}=1 + bitcoind.rpcpass={{ .Values.global.rpcpassword }} + bitcoind.rpchost={{ include "bitcoincore.fullname" . }}:{{ index .Values.global .Values.global.chain "RPCPort" }} + bitcoind.zmqpubrawblock=tcp://{{ include "bitcoincore.fullname" . }}:{{ .Values.global.ZMQBlockPort }} + bitcoind.zmqpubrawtx=tcp://{{ include "bitcoincore.fullname" . }}:{{ .Values.global.ZMQTxPort }} + alias={{ include "lnd.fullname" . }} + externalhosts={{ include "lnd.fullname" . }} + tlsextradomain={{ include "lnd.fullname" . }} diff --git a/resources/charts/lnd/templates/pod.yaml b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml similarity index 98% rename from resources/charts/lnd/templates/pod.yaml rename to resources/charts/bitcoincore/charts/lnd/templates/pod.yaml index f411ddeed..618005b22 100644 --- a/resources/charts/lnd/templates/pod.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml @@ -14,6 +14,7 @@ metadata: {{- if .Values.collectLogs }} collect_logs: "true" {{- end }} + chain: {{ .Values.global.chain }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: diff --git a/resources/charts/lnd/templates/service.yaml b/resources/charts/bitcoincore/charts/lnd/templates/service.yaml similarity index 100% rename from resources/charts/lnd/templates/service.yaml rename to resources/charts/bitcoincore/charts/lnd/templates/service.yaml diff --git a/resources/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml similarity index 96% rename from resources/charts/lnd/values.yaml rename to resources/charts/bitcoincore/charts/lnd/values.yaml index 644dce35a..672769fae 100644 --- a/resources/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -111,7 +111,8 @@ baseConfig: | maxpendingchannels=64 trickledelay=1 rpclisten=0.0.0.0:10009 - # zmq* and bitcoind.{rpcuser, rpcpass} are set in warnet code + bitcoind.rpcuser=user + # zmq* and bitcoind.rpcpass are set in configmap.yaml config: "" diff --git a/resources/charts/bitcoincore/templates/_helpers.tpl b/resources/charts/bitcoincore/templates/_helpers.tpl index 26258b5de..81ab85a37 100644 --- a/resources/charts/bitcoincore/templates/_helpers.tpl +++ b/resources/charts/bitcoincore/templates/_helpers.tpl @@ -65,6 +65,6 @@ Always add for custom semver, check version for valid semver {{- $custom := contains "-" .Values.image.tag -}} {{- $newer := semverCompare ">=0.17.0" .Values.image.tag -}} {{- if or $newer $custom -}} -[{{ .Values.chain }}] +[{{ .Values.global.chain }}] {{- end -}} {{- end -}} diff --git a/resources/charts/bitcoincore/templates/configmap.yaml b/resources/charts/bitcoincore/templates/configmap.yaml index 36c5ab389..cc1e580f2 100644 --- a/resources/charts/bitcoincore/templates/configmap.yaml +++ b/resources/charts/bitcoincore/templates/configmap.yaml @@ -6,14 +6,14 @@ metadata: {{- include "bitcoincore.labels" . | nindent 4 }} data: bitcoin.conf: | - {{ .Values.chain }}=1 + {{ .Values.global.chain }}=1 {{ template "bitcoincore.check_semver" . }} {{- .Values.baseConfig | nindent 4 }} - rpcport={{ index .Values .Values.chain "RPCPort" }} - rpcpassword={{ .Values.rpcpassword }} - zmqpubrawblock=tcp://0.0.0.0:{{ .Values.ZMQBlockPort }} - zmqpubrawtx=tcp://0.0.0.0:{{ .Values.ZMQTxPort }} + rpcport={{ index .Values.global .Values.global.chain "RPCPort" }} + rpcpassword={{ .Values.global.rpcpassword }} + zmqpubrawblock=tcp://0.0.0.0:{{ .Values.global.ZMQBlockPort }} + zmqpubrawtx=tcp://0.0.0.0:{{ .Values.global.ZMQTxPort }} {{- .Values.defaultConfig | nindent 4 }} {{- .Values.config | nindent 4 }} {{- range .Values.addnode }} diff --git a/resources/charts/bitcoincore/templates/pod.yaml b/resources/charts/bitcoincore/templates/pod.yaml index 5664ddda3..56cd61958 100644 --- a/resources/charts/bitcoincore/templates/pod.yaml +++ b/resources/charts/bitcoincore/templates/pod.yaml @@ -7,11 +7,11 @@ metadata: {{- with .Values.podLabels }} {{- toYaml . | nindent 4 }} {{- end }} - chain: {{ .Values.chain }} - RPCPort: "{{ index .Values .Values.chain "RPCPort" }}" - ZMQTxPort: "{{ .Values.ZMQTxPort }}" - ZMQBlockPort: "{{ .Values.ZMQBlockPort }}" - rpcpassword: {{ .Values.rpcpassword }} + chain: {{ .Values.global.chain }} + RPCPort: "{{ index .Values.global .Values.global.chain "RPCPort" }}" + ZMQTxPort: "{{ .Values.global.ZMQTxPort }}" + ZMQBlockPort: "{{ .Values.global.ZMQBlockPort }}" + rpcpassword: {{ .Values.global.rpcpassword }} app: {{ include "bitcoincore.fullname" . }} {{- if .Values.collectLogs }} collect_logs: "true" @@ -34,8 +34,8 @@ spec: args: - | apk add --no-cache curl - mkdir -p /root/.bitcoin/{{ .Values.chain }} - curl -L {{ .Values.loadSnapshot.url }} | tar -xz -C /root/.bitcoin/{{ .Values.chain }} + mkdir -p /root/.bitcoin/{{ .Values.global.chain }} + curl -L {{ .Values.loadSnapshot.url }} | tar -xz -C /root/.bitcoin/{{ .Values.global.chain }} volumeMounts: - name: data mountPath: /root/.bitcoin @@ -48,23 +48,23 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: rpc - containerPort: {{ index .Values .Values.chain "RPCPort" }} + containerPort: {{ index .Values.global .Values.global.chain "RPCPort" }} protocol: TCP - name: p2p - containerPort: {{ index .Values .Values.chain "P2PPort" }} + containerPort: {{ index .Values.global .Values.global.chain "P2PPort" }} protocol: TCP - name: zmq-tx - containerPort: {{ .Values.ZMQTxPort }} + containerPort: {{ .Values.global.ZMQTxPort }} protocol: TCP - name: zmq-block - containerPort: {{ .Values.ZMQBlockPort }} + containerPort: {{ .Values.global.ZMQBlockPort }} protocol: TCP livenessProbe: {{- toYaml .Values.livenessProbe | nindent 8 }} readinessProbe: {{- toYaml .Values.readinessProbe | nindent 8 }} tcpSocket: - port: {{ index .Values .Values.chain "RPCPort" }} + port: {{ index .Values.global .Values.global.chain "RPCPort" }} resources: {{- toYaml .Values.resources | nindent 8 }} volumeMounts: @@ -88,11 +88,11 @@ spec: - name: BITCOIN_RPC_HOST value: "127.0.0.1" - name: BITCOIN_RPC_PORT - value: "{{ index .Values .Values.chain "RPCPort" }}" + value: "{{ index .Values.global .Values.global.chain "RPCPort" }}" - name: BITCOIN_RPC_USER value: user - name: BITCOIN_RPC_PASSWORD - value: {{ .Values.rpcpassword }} + value: {{ .Values.global.rpcpassword }} {{- if .Values.metrics }} - name: METRICS value: {{ .Values.metrics }} diff --git a/resources/charts/bitcoincore/templates/service.yaml b/resources/charts/bitcoincore/templates/service.yaml index f37c384ef..8d8fa5324 100644 --- a/resources/charts/bitcoincore/templates/service.yaml +++ b/resources/charts/bitcoincore/templates/service.yaml @@ -8,19 +8,19 @@ metadata: spec: type: {{ .Values.service.type }} ports: - - port: {{ index .Values .Values.chain "RPCPort" }} + - port: {{ index .Values.global .Values.global.chain "RPCPort" }} targetPort: rpc protocol: TCP name: rpc - - port: {{ index .Values .Values.chain "P2PPort" }} + - port: {{ index .Values.global .Values.global.chain "P2PPort" }} targetPort: p2p protocol: TCP name: p2p - - port: {{ .Values.ZMQTxPort }} + - port: {{ .Values.global.ZMQTxPort }} targetPort: zmq-tx protocol: TCP name: zmq-tx - - port: {{ .Values.ZMQBlockPort }} + - port: {{ .Values.global.ZMQBlockPort }} targetPort: zmq-block protocol: TCP name: zmq-block diff --git a/resources/charts/bitcoincore/values.yaml b/resources/charts/bitcoincore/values.yaml index 6314ae32c..8c9f3215f 100644 --- a/resources/charts/bitcoincore/values.yaml +++ b/resources/charts/bitcoincore/values.yaml @@ -33,17 +33,6 @@ securityContext: {} service: type: ClusterIP -regtest: - RPCPort: 18443 - P2PPort: 18444 - -signet: - RPCPort: 38332 - P2PPort: 38333 - -ZMQTxPort: 28333 -ZMQBlockPort: 28332 - ingress: enabled: false className: "" @@ -109,12 +98,23 @@ tolerations: [] affinity: {} -chain: regtest - collectLogs: false metricsExport: false prometheusMetricsPort: 9332 +# These are values that are propogated to the sub-charts (i.e. lightning nodes) +global: + chain: regtest + regtest: + RPCPort: 18443 + P2PPort: 18444 + signet: + RPCPort: 38332 + P2PPort: 38333 + ZMQTxPort: 28333 + ZMQBlockPort: 28332 + rpcpassword: gn0cchi + baseConfig: | checkmempool=0 debuglogfile=debug.log @@ -130,8 +130,6 @@ baseConfig: | rest=1 # rpcport and zmq endpoints are configured by chain in configmap.yaml -rpcpassword: gn0cchi - config: "" defaultConfig: "" @@ -141,3 +139,6 @@ addnode: [] loadSnapshot: enabled: false url: "" + +ln: + lnd: false \ No newline at end of file diff --git a/resources/charts/lnd/templates/configmap.yaml b/resources/charts/lnd/templates/configmap.yaml deleted file mode 100644 index 0f3bfa18c..000000000 --- a/resources/charts/lnd/templates/configmap.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "lnd.fullname" . }} - labels: - {{- include "lnd.labels" . | nindent 4 }} -data: - lnd.conf: | - {{- .Values.baseConfig | nindent 4 }} - {{- .Values.defaultConfig | nindent 4 }} - {{- .Values.config | nindent 4 }} diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index bc657d0bf..97cfa0f90 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -15,7 +15,6 @@ FORK_OBSERVER_CHART, HELM_COMMAND, INGRESS_HELM_COMMANDS, - LND_CHART_LOCATION, LOGGING_HELM_COMMANDS, LOGGING_NAMESPACE, NAMESPACES_CHART_LOCATION, @@ -28,7 +27,6 @@ get_default_namespace_or, get_mission, get_namespaces_by_type, - get_pod, wait_for_ingress_controller, wait_for_pod_ready, ) @@ -263,10 +261,6 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str click.echo(f"Failed to run Helm command: {cmd}") return - lnd = node.get("lnd") - if lnd: - deploy_lnd(node, namespace) - except Exception as e: click.echo(f"Error: {e}") return @@ -275,43 +269,6 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str Path(temp_override_file_path).unlink() -def deploy_lnd(node: object, namespace: str): - node_name = node.get("name") - lnd_name = f"{node_name}-lnd" - - tank = get_pod(node_name, namespace) - - # A little harsh but for now let's make sure this always works - assert tank - - extra_conf = { - "extraLabels": {"chain": tank.metadata.labels["chain"]}, - "config": ("\n").join( - [ - f"bitcoin.{tank.metadata.labels['chain']}=1", - "bitcoind.rpcuser=user", - f"bitcoind.rpcpass={tank.metadata.labels['rpcpassword']}", - f"bitcoind.rpchost={node_name}:{int(tank.metadata.labels['RPCPort'])}", - f"bitcoind.zmqpubrawblock=tcp://{node_name}:{int(tank.metadata.labels['ZMQBlockPort'])}", - f"bitcoind.zmqpubrawtx=tcp://{node_name}:{int(tank.metadata.labels['ZMQTxPort'])}", - f"alias={lnd_name}", - f"externalhosts={lnd_name}", - f"tlsextradomain={lnd_name}", - ] - ), - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as temp_file: - yaml.dump(extra_conf, temp_file) - extra_conf_file_path = Path(temp_file.name) - - cmd = f"{HELM_COMMAND} {lnd_name} {LND_CHART_LOCATION} --namespace {namespace} -f {extra_conf_file_path}" - if not stream_command(cmd): - print(f"Failed to run Helm command: {cmd}") - return - Path(extra_conf_file_path).unlink() - - def deploy_namespaces(directory: Path): namespaces_file_path = directory / NAMESPACES_FILE defaults_file_path = directory / DEFAULTS_NAMESPACE_FILE diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 562c4a1d0..b917dc185 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -2,12 +2,17 @@ nodes: - name: tank-0000 addnode: - tank-0001 - lnd: true + ln: + lnd: true + - name: tank-0001 addnode: - tank-0002 - lnd: true + ln: + lnd: true + - name: tank-0002 addnode: - tank-0000 - lnd: true + ln: + lnd: true \ No newline at end of file diff --git a/test/data/signet/node-defaults.yaml b/test/data/signet/node-defaults.yaml index aea980d6a..941f03881 100644 --- a/test/data/signet/node-defaults.yaml +++ b/test/data/signet/node-defaults.yaml @@ -3,7 +3,8 @@ image: pullPolicy: Always tag: "27.0" -chain: signet +global: + chain: signet spec: restartPolicy: Never diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index 473d9b02c..20ec965c6 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -30,12 +30,12 @@ def setup_network(self): self.wait_for_all_tanks_status(target="running") def wait_for_all_ln_rpc(): - nodes = ["tank-0000-lnd", "tank-0001-lnd", "tank-0002-lnd"] + nodes = ["tank-0000-ln", "tank-0001-ln", "tank-0002-ln"] for node in nodes: try: self.warnet(f"ln rpc {node} getinfo") - except Exception as e: - print(f"LN node {node} not ready for rpc yet: {e}") + except Exception: + print(f"LN node {node} not ready for rpc yet") return False return True @@ -49,7 +49,7 @@ def fund_wallets(self): ) addrs = [] - for lnd in ["tank-0000-lnd", "tank-0001-lnd", "tank-0002-lnd"]: + for lnd in ["tank-0000-ln", "tank-0001-ln", "tank-0002-ln"]: addrs.append(json.loads(self.warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"]) self.warnet( @@ -61,27 +61,27 @@ def fund_wallets(self): def manual_open_channels(self): # 0 -> 1 -> 2 - pk1 = self.warnet("ln pubkey tank-0001-lnd") - pk2 = self.warnet("ln pubkey tank-0002-lnd") + pk1 = self.warnet("ln pubkey tank-0001-ln") + pk2 = self.warnet("ln pubkey tank-0002-ln") host1 = None host2 = None while not host1 or not host2: if not host1: - host1 = self.warnet("ln host tank-0001-lnd") + host1 = self.warnet("ln host tank-0001-ln") if not host2: - host2 = self.warnet("ln host tank-0002-lnd") + host2 = self.warnet("ln host tank-0002-ln") sleep(1) print( self.warnet( - f"ln rpc tank-0000-lnd openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" + f"ln rpc tank-0000-ln openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" ) ) print( self.warnet( - f"ln rpc tank-0001-lnd openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" + f"ln rpc tank-0001-ln openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" ) ) @@ -99,20 +99,20 @@ def wait_for_gossip_sync(self): while len(chs0) != 2 or len(chs1) != 2 or len(chs2) != 2: if len(chs0) != 2: - chs0 = json.loads(self.warnet("ln rpc tank-0000-lnd describegraph"))["edges"] + chs0 = json.loads(self.warnet("ln rpc tank-0000-ln describegraph"))["edges"] if len(chs1) != 2: - chs1 = json.loads(self.warnet("ln rpc tank-0001-lnd describegraph"))["edges"] + chs1 = json.loads(self.warnet("ln rpc tank-0001-ln describegraph"))["edges"] if len(chs2) != 2: - chs2 = json.loads(self.warnet("ln rpc tank-0002-lnd describegraph"))["edges"] + chs2 = json.loads(self.warnet("ln rpc tank-0002-ln describegraph"))["edges"] sleep(1) def pay_invoice(self): - inv = json.loads(self.warnet("ln rpc tank-0002-lnd addinvoice --amt 1000")) + inv = json.loads(self.warnet("ln rpc tank-0002-ln addinvoice --amt 1000")) print(inv) - print(self.warnet(f"ln rpc tank-0000-lnd payinvoice -f {inv['payment_request']}")) + print(self.warnet(f"ln rpc tank-0000-ln payinvoice -f {inv['payment_request']}")) def wait_for_success(): - return json.loads(self.warnet("ln rpc tank-0002-lnd channelbalance"))["balance"] == 1000 + return json.loads(self.warnet("ln rpc tank-0002-ln channelbalance"))["balance"] == 1000 self.wait_for_predicate(wait_for_success) From fac8d11e7cf0640270ce4ccb585afaf7f31abaec Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 8 Nov 2024 12:26:04 -0500 Subject: [PATCH 05/72] store channel data from network.yaml in configmaps to open channels --- .../charts/lnd/templates/configmap.yaml | 12 ++ .../bitcoincore/charts/lnd/templates/pod.yaml | 3 - .../charts/bitcoincore/charts/lnd/values.yaml | 2 +- src/warnet/deploy.py | 2 +- src/warnet/k8s.py | 14 ++- src/warnet/ln.py | 51 +++++++-- test/data/ln/network.yaml | 45 +++++++- test/data/ln/node-defaults.yaml | 4 + test/ln_basic_test.py | 106 +++++++++++------- 9 files changed, 182 insertions(+), 57 deletions(-) diff --git a/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml b/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml index f1477c1d6..5e4635adb 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml @@ -17,3 +17,15 @@ data: alias={{ include "lnd.fullname" . }} externalhosts={{ include "lnd.fullname" . }} tlsextradomain={{ include "lnd.fullname" . }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "lnd.fullname" . }}-channels + labels: + channels: "true" + {{- include "lnd.labels" . | nindent 4 }} +data: + source: {{ include "lnd.fullname" . }} + channels: | + {{ .Values.channels | toJson }} diff --git a/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml index 618005b22..347217b32 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml @@ -4,9 +4,6 @@ metadata: name: {{ include "lnd.fullname" . }} labels: {{- include "lnd.labels" . | nindent 4 }} - {{- with .Values.extraLabels }} - {{- toYaml . | nindent 4 }} - {{- end }} {{- with .Values.podLabels }} {{- toYaml . | nindent 4 }} {{- end }} diff --git a/resources/charts/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml index 672769fae..6681da560 100644 --- a/resources/charts/bitcoincore/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -118,4 +118,4 @@ config: "" defaultConfig: "" -extraLabels: {} +channels: [] diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 97cfa0f90..59d8d057f 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -243,7 +243,7 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str try: temp_override_file_path = "" node_name = node.get("name") - node_config_override = {k: v for k, v in node.items() if k != "name" and k != "lnd"} + node_config_override = {k: v for k, v in node.items() if k != "name"} cmd = f"{HELM_COMMAND} {node_name} {BITCOIN_CHART_LOCATION} --namespace {namespace} -f {defaults_file_path}" if debug: diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 9354eb903..d11214d04 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -83,11 +83,19 @@ def get_pod_exit_status(pod_name, namespace: Optional[str] = None): return None -def get_edges(namespace: Optional[str] = None) -> any: +def get_channels(namespace: Optional[str] = None) -> any: namespace = get_default_namespace_or(namespace) sclient = get_static_client() - configmap = sclient.read_namespaced_config_map(name="edges", namespace=namespace) - return json.loads(configmap.data["data"]) + config_maps = sclient.list_namespaced_config_map( + namespace=namespace, label_selector="channels=true" + ) + channels = [] + for cm in config_maps.items: + channel_jsons = json.loads(cm.data["channels"]) + for channel_json in channel_jsons: + channel_json["source"] = cm.data["source"] + channels.append(channel_json) + return channels def create_kubernetes_object( diff --git a/src/warnet/ln.py b/src/warnet/ln.py index 22ab86630..6296cf6ed 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -3,7 +3,11 @@ import click -from .k8s import get_default_namespace_or, get_pod +from .k8s import ( + get_channels, + get_default_namespace_or, + get_pod, +) from .process import run_command @@ -25,8 +29,6 @@ def rpc(pod: str, method: str, params: str, namespace: Optional[str]): def _rpc(pod_name: str, method: str, params: str = "", namespace: Optional[str] = None): - # TODO: when we add back cln we'll need to describe the pod, - # get a label with implementation type and then adjust command pod = get_pod(pod_name) namespace = get_default_namespace_or(namespace) chain = pod.metadata.labels["chain"] @@ -42,9 +44,12 @@ def pubkey( """ Get lightning node pub key from """ - # TODO: again here, cln will need a different command + print(_pubkey(pod)) + + +def _pubkey(pod: str): info = _rpc(pod, "getinfo") - print(json.loads(info)["identity_pubkey"]) + return json.loads(info)["identity_pubkey"] @ln.command() @@ -55,8 +60,40 @@ def host( """ Get lightning node host from """ - # TODO: again here, cln will need a different command + print(_host(pod)) + + +def _host(pod): info = _rpc(pod, "getinfo") uris = json.loads(info)["uris"] if uris and len(uris) >= 0: - print(uris[0].split("@")[1]) + return uris[0].split("@")[1] + else: + return "" + + +@ln.command() +def open_all_channels(): + """ + Open all channels with source policies defined in the network.yaml + IGNORES HARD CODED CHANNEL IDs + Should only be run once or you'll end up with duplicate channels + """ + channels = get_channels() + commands = [] + for ch in channels: + pk = _pubkey(ch["target"]) + host = _host(ch["target"]) + local_amt = ch["local_amt"] + push_amt = ch.get("push_amt", 0) + assert pk, f"{ch['target']} has no public key" + assert host, f"{ch['target']} has no host" + assert local_amt, "Channel has no local_amount" + commands.append( + ( + ch["source"], + f"openchannel --node_key {pk} --connect {host} --local_amt {local_amt} --push_amt {push_amt}", + ) + ) + for command in commands: + _rpc(*command) diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index b917dc185..4050dda72 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -15,4 +15,47 @@ nodes: addnode: - tank-0000 ln: - lnd: true \ No newline at end of file + 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 + local_amt: 100000 + push_amt: 50000 + + - name: tank-0004 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + channels: + - id: + block: 300 + index: 2 + target: tank-0005-ln + local_amt: 50000 + push_amt: 25000 + + - name: tank-0005 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + channels: + - id: + block: 301 + index: 1 + target: tank-0000-ln + local_amt: 25000 diff --git a/test/data/ln/node-defaults.yaml b/test/data/ln/node-defaults.yaml index 7e021cad1..884ad1343 100644 --- a/test/data/ln/node-defaults.yaml +++ b/test/data/ln/node-defaults.yaml @@ -2,3 +2,7 @@ image: repository: bitcoindevproject/bitcoin pullPolicy: IfNotPresent tag: "27.0" + +lnd: + defaultConfig: | + color=#000000 \ No newline at end of file diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index 20ec965c6..d14955e40 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -12,15 +12,33 @@ class LNBasicTest(TestBase): def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" - self.miner_addr = "" + self.lns = [ + "tank-0000-ln", + "tank-0001-ln", + "tank-0002-ln", + "tank-0003-ln", + "tank-0004-ln", + "tank-0005-ln", + ] def run_test(self): try: + # Wait for all nodes to wake up self.setup_network() + # Send money to all LN nodes self.fund_wallets() + + # Manually open two channels between first three nodes + # and send a payment self.manual_open_channels() - self.wait_for_gossip_sync() - self.pay_invoice() + self.wait_for_gossip_sync(self.lns[:3], 2) + self.pay_invoice(sender="tank-0000-ln", recipient="tank-0002-ln") + + # Automatically open channels from network.yaml + self.automatic_open_channels() + self.wait_for_gossip_sync(self.lns[3:], 3) + # push_amt should enable payments from target to source + self.pay_invoice(sender="tank-0005-ln", recipient="tank-0003-ln") finally: self.cleanup() @@ -29,34 +47,32 @@ def setup_network(self): self.log.info(self.warnet(f"deploy {self.network_dir}")) self.wait_for_all_tanks_status(target="running") + self.warnet("bitcoin rpc tank-0000 createwallet miner") + self.warnet("bitcoin rpc tank-0000 -generate 110") + self.wait_for_predicate( + lambda: int(self.warnet("bitcoin rpc tank-0000 getblockcount")) > 100 + ) + def wait_for_all_ln_rpc(): - nodes = ["tank-0000-ln", "tank-0001-ln", "tank-0002-ln"] - for node in nodes: + for ln in self.lns: try: - self.warnet(f"ln rpc {node} getinfo") + self.warnet(f"ln rpc {ln} getinfo") except Exception: - print(f"LN node {node} not ready for rpc yet") + print(f"LN node {ln} not ready for rpc yet") return False return True self.wait_for_predicate(wait_for_all_ln_rpc) def fund_wallets(self): - self.warnet("bitcoin rpc tank-0000 createwallet miner") - self.warnet("bitcoin rpc tank-0000 -generate 110") - self.wait_for_predicate( - lambda: int(self.warnet("bitcoin rpc tank-0000 getblockcount")) > 100 - ) - - addrs = [] - for lnd in ["tank-0000-ln", "tank-0001-ln", "tank-0002-ln"]: - addrs.append(json.loads(self.warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"]) - - self.warnet( - "bitcoin rpc tank-0000 sendmany '' '{" - + f'"{addrs[0]}":10,"{addrs[1]}":10,"{addrs[2]}":10' - + "}'" - ) + outputs = "" + for lnd in self.lns: + addr = json.loads(self.warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"] + outputs += f',"{addr}":10' + # trim first comma + outputs = outputs[1:] + + self.warnet("bitcoin rpc tank-0000 sendmany '' '{" + outputs + "}'") self.warnet("bitcoin rpc tank-0000 -generate 1") def manual_open_channels(self): @@ -64,8 +80,8 @@ def manual_open_channels(self): pk1 = self.warnet("ln pubkey tank-0001-ln") pk2 = self.warnet("ln pubkey tank-0002-ln") - host1 = None - host2 = None + host1 = "" + host2 = "" while not host1 or not host2: if not host1: @@ -92,28 +108,36 @@ def wait_for_two_txs(): self.warnet("bitcoin rpc tank-0000 -generate 10") - def wait_for_gossip_sync(self): - chs0 = [] - chs1 = [] - chs2 = [] - - while len(chs0) != 2 or len(chs1) != 2 or len(chs2) != 2: - if len(chs0) != 2: - chs0 = json.loads(self.warnet("ln rpc tank-0000-ln describegraph"))["edges"] - if len(chs1) != 2: - chs1 = json.loads(self.warnet("ln rpc tank-0001-ln describegraph"))["edges"] - if len(chs2) != 2: - chs2 = json.loads(self.warnet("ln rpc tank-0002-ln describegraph"))["edges"] + def wait_for_gossip_sync(self, nodes, expected): + while len(nodes) > 0: + for node in nodes: + chs = json.loads(self.warnet(f"ln rpc {node} describegraph"))["edges"] + if len(chs) >= expected: + nodes.remove(node) sleep(1) - def pay_invoice(self): - inv = json.loads(self.warnet("ln rpc tank-0002-ln addinvoice --amt 1000")) + def pay_invoice(self, sender: str, recipient: str): + init_balance = int(json.loads(self.warnet(f"ln rpc {recipient} channelbalance"))["balance"]) + inv = json.loads(self.warnet(f"ln rpc {recipient} addinvoice --amt 1000")) print(inv) - print(self.warnet(f"ln rpc tank-0000-ln payinvoice -f {inv['payment_request']}")) + print(self.warnet(f"ln rpc {sender} payinvoice -f {inv['payment_request']}")) def wait_for_success(): - return json.loads(self.warnet("ln rpc tank-0002-ln channelbalance"))["balance"] == 1000 - self.wait_for_predicate(wait_for_success) + return ( + int(json.loads(self.warnet(f"ln rpc {recipient} channelbalance"))["balance"]) + == init_balance + 1000 + ) + + self.wait_for_predicate(wait_for_success) + + def automatic_open_channels(self): + self.warnet("ln open-all-channels") + + def wait_for_three_txs(): + return json.loads(self.warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 3 + + self.wait_for_predicate(wait_for_three_txs) + self.warnet("bitcoin rpc tank-0000 -generate 10") if __name__ == "__main__": From 6a4b46a97a82860d3d405498e79ac3676b960403 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 8 Nov 2024 15:26:32 -0500 Subject: [PATCH 06/72] import LN json --- .../charts/bitcoincore/charts/lnd/values.yaml | 1 + src/warnet/graph.py | 89 +++++++++++++++++++ src/warnet/main.py | 3 +- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/resources/charts/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml index 6681da560..490bb3e18 100644 --- a/resources/charts/bitcoincore/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -112,6 +112,7 @@ baseConfig: | trickledelay=1 rpclisten=0.0.0.0:10009 bitcoind.rpcuser=user + protocol.wumbo-channels=1 # zmq* and bitcoind.rpcpass are set in configmap.yaml config: "" diff --git a/src/warnet/graph.py b/src/warnet/graph.py index 390686486..193b6d9f4 100644 --- a/src/warnet/graph.py +++ b/src/warnet/graph.py @@ -1,3 +1,4 @@ +import json import os import random import sys @@ -226,3 +227,91 @@ def create(): fg="yellow", ) return False + + +@click.command() +@click.argument("graph_file_path", type=click.Path(exists=True, file_okay=True, dir_okay=False)) +@click.argument("output_path", type=click.Path(exists=False, file_okay=False, dir_okay=True)) +def import_network(graph_file_path: str, output_path: str): + """Create a network from an imported lightning network graph JSON""" + print(_import_network(graph_file_path, output_path)) + + +def _import_network(graph_file_path, output_path): + output_path = Path(output_path) + graph_file_path = Path(graph_file_path).resolve() + with open(graph_file_path) as graph_file: + graph = json.loads(graph_file.read()) + + tanks = {} + pk_to_tank = {} + tank_to_pk = {} + index = 0 + for node in graph["nodes"]: + tank = f"tank-{index:04d}" + pk_to_tank[node["pub_key"]] = tank + tank_to_pk[tank] = node["pub_key"] + tanks[tank] = {"name": tank, "ln": {"lnd": True}, "lnd": {"channels": []}} + index += 1 + print(f"Imported {index} nodes") + + sorted_edges = sorted(graph["edges"], key=lambda x: int(x["channel_id"])) + + supported_policies = [ + "base_fee_msat", + "fee_rate_ppm", + "time_lock_delta", + "min_htlc_msat", + "max_htlc_msat", + ] + + for_fuck_sake_lnd_what_is_your_fucking_problem = {"min_htlc": "min_htlc_msat"} + + def import_policy(json_policy): + for ugh in for_fuck_sake_lnd_what_is_your_fucking_problem: + if ugh in json_policy: + new_key = for_fuck_sake_lnd_what_is_your_fucking_problem[ugh] + json_policy[new_key] = json_policy[ugh] + return {key: int(json_policy[key]) for key in supported_policies if key in json_policy} + + # By default we start including channel open txs in block 300 + block = 300 + # Coinbase occupies the 0 position! + index = 1 + count = 0 + for edge in sorted_edges: + source = pk_to_tank[edge["node1_pub"]] + amt = int(edge["capacity"]) // 2 + channel = { + "id": {"block": block, "index": index}, + "target": pk_to_tank[edge["node2_pub"]] + "-ln", + "local_amt": amt, + "push_amt": amt - 1, + "source_policy": import_policy(edge["node1_policy"]), + "target_policy": import_policy(edge["node2_policy"]), + } + tanks[source]["lnd"]["channels"].append(channel) + index += 1 + if index > 1000: + index = 1 + block += 1 + count += 1 + + print(f"Imported {count} channels") + + network = {"nodes": []} + prev_node_name = list(tanks.keys())[-1] + for name, obj in tanks.items(): + obj["name"] = name + obj["addnode"] = [prev_node_name] + prev_node_name = name + network["nodes"].append(obj) + + output_path.mkdir(parents=True, exist_ok=True) + # This file must exist and must contain at least one line of valid yaml + with open(output_path / "node-defaults.yaml", "w") as f: + f.write(f"imported_from: {graph_file_path}\n") + # Here's the good stuff + with open(output_path / "network.yaml", "w") as f: + f.write(yaml.dump(network, sort_keys=False)) + return f"Network created in {output_path.resolve()}" diff --git a/src/warnet/main.py b/src/warnet/main.py index ffc24cf78..868147748 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -5,7 +5,7 @@ from .control import down, logs, run, snapshot, stop from .dashboard import dashboard from .deploy import deploy -from .graph import create, graph +from .graph import create, graph, import_network from .image import image from .ln import ln from .project import init, new, setup @@ -25,6 +25,7 @@ def cli(): cli.add_command(down) cli.add_command(dashboard) cli.add_command(graph) +cli.add_command(import_network) cli.add_command(image) cli.add_command(init) cli.add_command(logs) From 4574eb6cd222192d1a429e3b8041e7fe9d86999d Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 01:50:56 -0600 Subject: [PATCH 07/72] Add the hook api --- resources/plugins/__init__.py | 0 resources/plugins/demo.py | 11 +++ src/warnet/constants.py | 6 ++ src/warnet/hooks.py | 140 ++++++++++++++++++++++++++++++++++ src/warnet/network.py | 13 ++++ src/warnet/project.py | 3 +- src/warnet/status.py | 2 + 7 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 resources/plugins/__init__.py create mode 100644 resources/plugins/demo.py create mode 100644 src/warnet/hooks.py diff --git a/resources/plugins/__init__.py b/resources/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/resources/plugins/demo.py b/resources/plugins/demo.py new file mode 100644 index 000000000..fad3c428e --- /dev/null +++ b/resources/plugins/demo.py @@ -0,0 +1,11 @@ +from hooks_api import post_status, pre_status + + +@pre_status +def print_something_wonderful(): + print("This has been a very pleasant day.") + + +@post_status +def print_something_afterwards(): + print("Status has run!") diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 46f33a3fe..ec6614bfc 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -37,6 +37,12 @@ NAMESPACES_FILE = "namespaces.yaml" DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" +# Plugin architecture +PLUGINS_DIR = RESOURCES_DIR.joinpath("plugins") +HOOK_NAME_KEY = "hook_name" # this lives as a key in object.__annotations__ +HOOKS_API_STEM = "hooks_api" +HOOKS_API_FILE = HOOKS_API_STEM + ".py" + # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) FORK_OBSERVER_CHART = str(CHARTS_DIR.joinpath("fork-observer")) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py new file mode 100644 index 000000000..9608766d2 --- /dev/null +++ b/src/warnet/hooks.py @@ -0,0 +1,140 @@ +import importlib.util +import inspect +import os +import sys +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any, Callable + +from warnet.constants import HOOK_NAME_KEY, HOOKS_API_FILE, HOOKS_API_STEM + +hook_registry: set[str] = set() +imported_modules = {} + + +def api(func: Callable[..., Any]) -> Callable[..., Any]: + """ + Functions with this decoration will have corresponding 'pre' and 'post' functions made + available to the user via the 'plugins' directory. + + Please ensure that @api is the innermost decorator: + + ```python + @click.command() # outermost + @api # innermost + def my_function(): + pass + ``` + """ + if func.__name__ in hook_registry: + print( + f"Cannot re-use function names in the Warnet plugin API -- " + f"'{func.__name__}' has already been taken." + ) + sys.exit(1) + hook_registry.add(func.__name__) + + if not imported_modules: + load_user_modules() + + pre_hooks, post_hooks = [], [] + for module_name in imported_modules: + pre, post = find_hooks(module_name, func.__name__) + pre_hooks.extend(pre) + post_hooks.extend(post) + + def wrapped(*args, **kwargs): + for hook in pre_hooks: + hook() + result = func(*args, **kwargs) + for hook in post_hooks: + hook() + return result + + # Mimic the base function; helps make `click` happy + wrapped.__name__ = func.__name__ + wrapped.__doc__ = func.__doc__ + + return wrapped + + +def create_hooks(directory: Path): + # Prepare directory and file + os.makedirs(directory, exist_ok=True) + init_file_path = os.path.join(directory, HOOKS_API_FILE) + + with open(init_file_path, "w") as file: + file.write(f"# API Version: {get_version('warnet')}") + # For each enum variant, create a corresponding decorator function + for hook in hook_registry: + file.write(decorator_code.format(hook=hook, HOOK_NAME_KEY=HOOK_NAME_KEY)) + + +decorator_code = """ + + +def pre_{hook}(func): + \"\"\"Functions with this decoration run before `{hook}`.\"\"\" + func.__annotations__['{HOOK_NAME_KEY}'] = 'pre_{hook}' + return func + + +def post_{hook}(func): + \"\"\"Functions with this decoration run after `{hook}`.\"\"\" + func.__annotations__['{HOOK_NAME_KEY}'] = 'post_{hook}' + return func +""" + + +def load_user_modules() -> bool: + was_successful_load = False + user_module_path = Path.cwd() / "plugins" + + if not user_module_path.is_dir(): + print("No plugins folder found in the current directory") + return was_successful_load + + # Temporarily add the current directory to sys.path for imports + sys.path.insert(0, str(Path.cwd())) + + hooks_path = user_module_path / HOOKS_API_FILE + if hooks_path.is_file(): + hooks_spec = importlib.util.spec_from_file_location(HOOKS_API_STEM, hooks_path) + hooks_module = importlib.util.module_from_spec(hooks_spec) + imported_modules[HOOKS_API_STEM] = hooks_module + sys.modules[HOOKS_API_STEM] = hooks_module + hooks_spec.loader.exec_module(hooks_module) + + for file in user_module_path.glob("*.py"): + if file.stem not in ("__init__", HOOKS_API_STEM): + module_name = f"plugins.{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 find_hooks(module_name: str, func_name: str): + module = imported_modules.get(module_name) + pre_hooks = [] + post_hooks = [] + for _, func in inspect.getmembers(module, inspect.isfunction): + if func.__annotations__.get(HOOK_NAME_KEY) == f"pre_{func_name}": + pre_hooks.append(func) + elif func.__annotations__.get(HOOK_NAME_KEY) == f"post_{func_name}": + post_hooks.append(func) + return pre_hooks, post_hooks + + +def get_version(package_name: str) -> str: + try: + return version(package_name) + except PackageNotFoundError: + print(f"Package not found: {package_name}") + sys.exit(1) diff --git a/src/warnet/network.py b/src/warnet/network.py index a894cafc9..0d677e7ce 100644 --- a/src/warnet/network.py +++ b/src/warnet/network.py @@ -7,8 +7,10 @@ from .bitcoin import _rpc from .constants import ( NETWORK_DIR, + PLUGINS_DIR, SCENARIOS_DIR, ) +from .hooks import create_hooks from .k8s import get_mission @@ -48,6 +50,17 @@ 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__"], + ) + create_hooks(directory / PLUGINS_DIR.name) + + 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..60ab3fef1 100644 --- a/src/warnet/status.py +++ b/src/warnet/status.py @@ -8,11 +8,13 @@ from rich.text import Text from urllib3.exceptions import MaxRetryError +from .hooks import api from .k8s import get_mission from .network import _connected @click.command() +@api def status(): """Display the unified status of the Warnet network and active scenarios""" console = Console() From 61109a7e01278c43648926e6a2d97590cc20e3ec Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 10:37:36 -0600 Subject: [PATCH 08/72] hooks: reduce the noise from the plugin check This text disrupts other commands whenever a `plugins` directory is not available. --- src/warnet/hooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 9608766d2..979345d1f 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -91,7 +91,6 @@ def load_user_modules() -> bool: user_module_path = Path.cwd() / "plugins" if not user_module_path.is_dir(): - print("No plugins folder found in the current directory") return was_successful_load # Temporarily add the current directory to sys.path for imports From df9f39cafb253ddb45f3bc5dbe8954cf820057f4 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 11:14:45 -0600 Subject: [PATCH 09/72] hooks: add func docs to hooks_api.py --- src/warnet/hooks.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 979345d1f..8984db911 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -8,7 +8,7 @@ from warnet.constants import HOOK_NAME_KEY, HOOKS_API_FILE, HOOKS_API_STEM -hook_registry: set[str] = set() +hook_registry: set[Callable[..., Any]] = set() imported_modules = {} @@ -26,13 +26,13 @@ def my_function(): pass ``` """ - if func.__name__ in hook_registry: + if func.__name__ in [fn.__name__ for fn in hook_registry]: print( f"Cannot re-use function names in the Warnet plugin API -- " f"'{func.__name__}' has already been taken." ) sys.exit(1) - hook_registry.add(func.__name__) + hook_registry.add(func) if not imported_modules: load_user_modules() @@ -66,21 +66,35 @@ def create_hooks(directory: Path): with open(init_file_path, "w") as file: file.write(f"# API Version: {get_version('warnet')}") # For each enum variant, create a corresponding decorator function - for hook in hook_registry: - file.write(decorator_code.format(hook=hook, HOOK_NAME_KEY=HOOK_NAME_KEY)) + for func in hook_registry: + file.write( + decorator_code.format( + hook=func.__name__, doc=func.__doc__, HOOK_NAME_KEY=HOOK_NAME_KEY + ) + ) decorator_code = """ def pre_{hook}(func): - \"\"\"Functions with this decoration run before `{hook}`.\"\"\" + \"\"\" + Functions with this decoration run before `{hook}`. + + `{hook}` documentation: + {doc} + \"\"\" func.__annotations__['{HOOK_NAME_KEY}'] = 'pre_{hook}' return func def post_{hook}(func): - \"\"\"Functions with this decoration run after `{hook}`.\"\"\" + \"\"\" + Functions with this decoration run after `{hook}`. + + `{hook}` documentation: + {doc} + \"\"\" func.__annotations__['{HOOK_NAME_KEY}'] = 'post_{hook}' return func """ From 6815d9b865704ca6de8408cd3416d1e58f5c6a0b Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 11:15:01 -0600 Subject: [PATCH 10/72] add @api to a number of functions --- src/warnet/control.py | 4 ++++ src/warnet/deploy.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/warnet/control.py b/src/warnet/control.py index 83d358a4e..9318ae5c7 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -25,6 +25,7 @@ COMMANDER_MISSION, TANK_MISSION, ) +from .hooks import api from .k8s import ( can_delete_pods, delete_pod, @@ -47,6 +48,7 @@ @click.command() @click.argument("scenario_name", required=False) +@api def stop(scenario_name): """Stop a running scenario or all scenarios""" active_scenarios = [sc.metadata.name for sc in get_mission("commander")] @@ -126,6 +128,7 @@ def stop_all_scenarios(scenarios): help="Skip confirmations", ) @click.command() +@api def down(force): """Bring down a running warnet quickly""" @@ -232,6 +235,7 @@ def get_active_network(namespace): ) @click.argument("additional_args", nargs=-1, type=click.UNPROCESSED) @click.option("--namespace", default=None, show_default=True) +@api def run( scenario_file: str, debug: bool, diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index d9b5a45b5..30c529460 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -22,6 +22,7 @@ NETWORK_FILE, WARGAMES_NAMESPACE_PREFIX, ) +from .hooks import api from .k8s import ( get_default_namespace, get_default_namespace_or, @@ -56,6 +57,7 @@ def validate_directory(ctx, param, value): @click.option("--namespace", type=str, help="Specify a namespace in which to deploy the network") @click.option("--to-all-users", is_flag=True, help="Deploy network to all user namespaces") @click.argument("unknown_args", nargs=-1) +@api def deploy(directory, debug, namespace, to_all_users, unknown_args): """Deploy a warnet with topology loaded from """ if unknown_args: From 43be6284460318a3c078ad3f9e1b616fa6622921 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 17:00:42 -0600 Subject: [PATCH 11/72] add rudimentary hooks test --- test/hooks_test.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100755 test/hooks_test.py 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() From 0e97c3d27605c32069023b9177c7540457058288 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 17:02:11 -0600 Subject: [PATCH 12/72] update hooks commands --- src/warnet/constants.py | 4 ++- src/warnet/hooks.py | 57 ++++++++++++++++++++++++++++++++++------- src/warnet/main.py | 2 ++ 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index ec6614bfc..a575877f8 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -38,10 +38,12 @@ DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" # Plugin architecture -PLUGINS_DIR = RESOURCES_DIR.joinpath("plugins") +PLUGINS_LABEL = "plugins" +PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_LABEL) HOOK_NAME_KEY = "hook_name" # this lives as a key in object.__annotations__ HOOKS_API_STEM = "hooks_api" HOOKS_API_FILE = HOOKS_API_STEM + ".py" +WARNET_USER_DIR_ENV_VAR = "WARNET_USER_DIR" # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 8984db911..2b595c63e 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -4,14 +4,37 @@ import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Optional -from warnet.constants import HOOK_NAME_KEY, HOOKS_API_FILE, HOOKS_API_STEM +import click + +from warnet.constants import ( + HOOK_NAME_KEY, + HOOKS_API_FILE, + HOOKS_API_STEM, + PLUGINS_LABEL, + WARNET_USER_DIR_ENV_VAR, +) hook_registry: set[Callable[..., Any]] = set() imported_modules = {} +@click.group(name="plugin") +def plugin(): + pass + + +@plugin.command() +def ls(): + plugin_dir = get_plugin_directory() + + if not plugin_dir: + click.secho("Could not determine the plugin directory location.") + click.secho("Consider setting environment variable containing your project directory:") + click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") + + def api(func: Callable[..., Any]) -> Callable[..., Any]: """ Functions with this decoration will have corresponding 'pre' and 'post' functions made @@ -73,6 +96,9 @@ def create_hooks(directory: Path): ) ) + click.secho("\nConsider setting environment variable containing your project directory:") + click.secho(f"export {WARNET_USER_DIR_ENV_VAR}={directory.parent}\n", fg="yellow") + decorator_code = """ @@ -102,15 +128,17 @@ def post_{hook}(func): def load_user_modules() -> bool: was_successful_load = False - user_module_path = Path.cwd() / "plugins" - if not user_module_path.is_dir(): + plugin_dir = get_plugin_directory() + + if not plugin_dir or not plugin_dir.is_dir(): return was_successful_load - # Temporarily add the current directory to sys.path for imports - sys.path.insert(0, str(Path.cwd())) + # Temporarily add the directory to sys.path for imports + sys.path.insert(0, str(plugin_dir)) + + hooks_path = plugin_dir / HOOKS_API_FILE - hooks_path = user_module_path / HOOKS_API_FILE if hooks_path.is_file(): hooks_spec = importlib.util.spec_from_file_location(HOOKS_API_STEM, hooks_path) hooks_module = importlib.util.module_from_spec(hooks_spec) @@ -118,9 +146,9 @@ def load_user_modules() -> bool: sys.modules[HOOKS_API_STEM] = hooks_module hooks_spec.loader.exec_module(hooks_module) - for file in user_module_path.glob("*.py"): + for file in plugin_dir.glob("*.py"): if file.stem not in ("__init__", HOOKS_API_STEM): - module_name = f"plugins.{file.stem}" + 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 @@ -145,6 +173,17 @@ def find_hooks(module_name: str, func_name: str): return pre_hooks, post_hooks +def get_plugin_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 get_version(package_name: str) -> str: try: return version(package_name) diff --git a/src/warnet/main.py b/src/warnet/main.py index 76893575c..fc6486eb3 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -6,6 +6,7 @@ from .dashboard import dashboard from .deploy import deploy from .graph import create, graph +from .hooks import plugin from .image import image from .project import init, new, setup from .status import status @@ -34,6 +35,7 @@ def cli(): cli.add_command(status) cli.add_command(stop) cli.add_command(create) +cli.add_command(plugin) if __name__ == "__main__": From 240680576ffda1287c2210ca6821d8ee98632ae3 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Tue, 12 Nov 2024 13:45:38 -0500 Subject: [PATCH 13/72] ln: same macaroon and cert for all nodes --- .../charts/lnd/templates/configmap.yaml | 21 ++++++++++++++ .../bitcoincore/charts/lnd/templates/pod.yaml | 17 +++++++++++ .../charts/bitcoincore/charts/lnd/values.yaml | 17 +++++++++-- resources/scripts/ssl/cert-gen.sh | 8 ++++++ resources/scripts/ssl/openssl-config.cnf | 28 +++++++++++++++++++ resources/scripts/ssl/tls.cert | 13 +++++++++ resources/scripts/ssl/tls.key | 5 ++++ 7 files changed, 106 insertions(+), 3 deletions(-) create mode 100755 resources/scripts/ssl/cert-gen.sh create mode 100644 resources/scripts/ssl/openssl-config.cnf create mode 100644 resources/scripts/ssl/tls.cert create mode 100644 resources/scripts/ssl/tls.key diff --git a/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml b/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml index 5e4635adb..65cd54cd6 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml @@ -17,6 +17,27 @@ data: alias={{ include "lnd.fullname" . }} externalhosts={{ include "lnd.fullname" . }} tlsextradomain={{ include "lnd.fullname" . }} + 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----- + --- apiVersion: v1 kind: ConfigMap diff --git a/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml index 347217b32..351037e85 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml @@ -32,10 +32,15 @@ spec: - name: p2p containerPort: {{ .Values.P2PPort }} protocol: TCP + - name: rest + containerPort: {{ .Values.RestPort }} + protocol: TCP livenessProbe: {{- toYaml .Values.livenessProbe | nindent 8 }} readinessProbe: {{- toYaml .Values.readinessProbe | nindent 8 }} + startupProbe: + {{- toYaml .Values.startupProbe | nindent 8 }} resources: {{- toYaml .Values.resources | nindent 8 }} volumeMounts: @@ -45,6 +50,12 @@ spec: - mountPath: /root/.lnd/lnd.conf name: config subPath: lnd.conf + - mountPath: /root/.lnd/tls.key + name: tlskey + subPath: tls.key + - mountPath: /root/.lnd/tls.cert + name: tlscert + subPath: tls.cert {{- if .Values.circuitBreaker }} - name: circuitbreaker image: pinheadmz/circuitbreaker:278737d @@ -57,6 +68,12 @@ spec: - configMap: name: {{ include "lnd.fullname" . }} name: config + - configMap: + name: {{ include "lnd.fullname" . }} + name: tlskey + - configMap: + name: {{ include "lnd.fullname" . }} + name: tlscert {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 4 }} diff --git a/resources/charts/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml index 490bb3e18..b6a68a2da 100644 --- a/resources/charts/bitcoincore/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -33,6 +33,7 @@ service: RPCPort: 10009 P2PPort: 9735 +RestPort: 8080 ingress: enabled: false @@ -80,7 +81,18 @@ readinessProbe: tcpSocket: port: 10009 timeoutSeconds: 1 - +startupProbe: + failureThreshold: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 10 + exec: + command: + - /bin/sh + - -c + - | + PHRASE=`curl --silent --insecure https://localhost:8080/v1/genseed | grep -o '\[[^]]*\]'` + curl --insecure https://localhost:8080/v1/initwallet --data "{\"macaroon_root_key\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\", \"wallet_password\":\"AAAAAAAAAAA=\", \"cipher_seed_mnemonic\": $PHRASE}" # Additional volumes on the output Deployment definition. volumes: [] @@ -102,8 +114,7 @@ tolerations: [] affinity: {} baseConfig: | - noseedbackup=true - norest=true + norest=false debuglevel=debug accept-keysend=true bitcoin.active=true diff --git a/resources/scripts/ssl/cert-gen.sh b/resources/scripts/ssl/cert-gen.sh new file mode 100755 index 000000000..c1370f884 --- /dev/null +++ b/resources/scripts/ssl/cert-gen.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Generate the private key using the P-256 curve +openssl ecparam -name prime256v1 -genkey -noout -out tls.key + +# Generate the self-signed certificate using the configuration file +# Expires in ten years, 2034 +openssl req -x509 -new -nodes -key tls.key -days 3650 -out tls.cert -config openssl-config.cnf diff --git a/resources/scripts/ssl/openssl-config.cnf b/resources/scripts/ssl/openssl-config.cnf new file mode 100644 index 000000000..db4e4a162 --- /dev/null +++ b/resources/scripts/ssl/openssl-config.cnf @@ -0,0 +1,28 @@ +[ req ] +distinguished_name = req_distinguished_name +req_extensions = req_ext +x509_extensions = v3_ca +prompt = no + +[ req_distinguished_name ] +O = lnd autogenerated cert +CN = warnet + +[ req_ext ] +keyUsage = critical, digitalSignature, keyEncipherment, keyCertSign +extendedKeyUsage = serverAuth +basicConstraints = critical, CA:true +subjectKeyIdentifier = hash + +[ v3_ca ] +keyUsage = critical, digitalSignature, keyEncipherment, keyCertSign +extendedKeyUsage = serverAuth +basicConstraints = critical, CA:true +subjectKeyIdentifier = hash +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = localhost +DNS.2 = * +IP.1 = 127.0.0.1 +IP.2 = ::1 diff --git a/resources/scripts/ssl/tls.cert b/resources/scripts/ssl/tls.cert new file mode 100644 index 000000000..6cf6e306a --- /dev/null +++ b/resources/scripts/ssl/tls.cert @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB8TCCAZagAwIBAgIUJDsR6mmY+TaO9pCfjtotlbOkzJMwCgYIKoZIzj0EAwIw +MjEfMB0GA1UECgwWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAwwGd2Fy +bmV0MB4XDTI0MTExMTE2NTM1MFoXDTM0MTEwOTE2NTM1MFowMjEfMB0GA1UECgwW +bG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAwwGd2FybmV0MFkwEwYHKoZI +zj0CAQYIKoZIzj0DAQcDQgAEBVltIvaTlAQI/3FFatTqVflZuZdRJ0SmRMSJrFLP +tp0fxE7hmteSt6gjQriy90fP8j9OJXBNAjt915kLY4zVvqOBiTCBhjAOBgNVHQ8B +Af8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQU5d8QMrwhLgTkDjWA+eXZGz+dybUwLwYDVR0RBCgwJoIJbG9jYWxo +b3N0ggEqhwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMAoGCCqGSM49BAMCA0kAMEYC +IQDPofN0fEl5gTwCYhk3nZbjMqJhZ8BsSJ6K8XRhxr7zbwIhAPsgQCFOqUWg632O +NEO53OQ6CIqnpxSskjsFNH4ZBQOE +-----END CERTIFICATE----- diff --git a/resources/scripts/ssl/tls.key b/resources/scripts/ssl/tls.key new file mode 100644 index 000000000..ca0118123 --- /dev/null +++ b/resources/scripts/ssl/tls.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIcFtWTLQv5JaRRxdkPKkO98OrvgeztbZ7h8Ev/4UbE4oAoGCCqGSM49 +AwEHoUQDQgAEBVltIvaTlAQI/3FFatTqVflZuZdRJ0SmRMSJrFLPtp0fxE7hmteS +t6gjQriy90fP8j9OJXBNAjt915kLY4zVvg== +-----END EC PRIVATE KEY----- From f43eeda59038061a48ee6c046e337f67acb024be Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:03 -0600 Subject: [PATCH 14/72] add ark plugin --- resources/plugins/ark/ark.py | 15 ++++++ resources/plugins/ark/charts/aspd/.helmignore | 23 ++++++++ resources/plugins/ark/charts/aspd/Chart.yaml | 5 ++ .../ark/charts/aspd/templates/NOTES.txt | 1 + .../ark/charts/aspd/templates/_helpers.tpl | 7 +++ .../ark/charts/aspd/templates/deployment.yaml | 52 +++++++++++++++++++ resources/plugins/ark/charts/aspd/values.yaml | 17 ++++++ resources/plugins/ark/charts/bark/.helmignore | 23 ++++++++ resources/plugins/ark/charts/bark/Chart.yaml | 5 ++ .../ark/charts/bark/templates/NOTES.txt | 1 + .../ark/charts/bark/templates/_helpers.tpl | 7 +++ .../ark/charts/bark/templates/pod.yaml | 14 +++++ resources/plugins/ark/charts/bark/values.yaml | 7 +++ resources/plugins/ark/plugin.yaml | 1 + 14 files changed, 178 insertions(+) create mode 100644 resources/plugins/ark/ark.py create mode 100644 resources/plugins/ark/charts/aspd/.helmignore create mode 100644 resources/plugins/ark/charts/aspd/Chart.yaml create mode 100644 resources/plugins/ark/charts/aspd/templates/NOTES.txt create mode 100644 resources/plugins/ark/charts/aspd/templates/_helpers.tpl create mode 100644 resources/plugins/ark/charts/aspd/templates/deployment.yaml create mode 100644 resources/plugins/ark/charts/aspd/values.yaml create mode 100644 resources/plugins/ark/charts/bark/.helmignore create mode 100644 resources/plugins/ark/charts/bark/Chart.yaml create mode 100644 resources/plugins/ark/charts/bark/templates/NOTES.txt create mode 100644 resources/plugins/ark/charts/bark/templates/_helpers.tpl create mode 100644 resources/plugins/ark/charts/bark/templates/pod.yaml create mode 100644 resources/plugins/ark/charts/bark/values.yaml create mode 100644 resources/plugins/ark/plugin.yaml diff --git a/resources/plugins/ark/ark.py b/resources/plugins/ark/ark.py new file mode 100644 index 000000000..d99292621 --- /dev/null +++ b/resources/plugins/ark/ark.py @@ -0,0 +1,15 @@ +from hooks_api import post_status, pre_status + + +@pre_status +def print_something_first(): + print("The ark plugin is enabled.") + + +@post_status +def print_something_afterwards(): + print("The ark plugin executes after `status` has run.") + + +def run(): + print("Running the ark plugin") diff --git a/resources/plugins/ark/charts/aspd/.helmignore b/resources/plugins/ark/charts/aspd/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/.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/ark/charts/aspd/Chart.yaml b/resources/plugins/ark/charts/aspd/Chart.yaml new file mode 100644 index 000000000..ec1dfaf3d --- /dev/null +++ b/resources/plugins/ark/charts/aspd/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: aspd +description: A Helm chart to deploy aspd +version: 0.1.0 +appVersion: "alpha-git-tag" diff --git a/resources/plugins/ark/charts/aspd/templates/NOTES.txt b/resources/plugins/ark/charts/aspd/templates/NOTES.txt new file mode 100644 index 000000000..d61b4f802 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/templates/NOTES.txt @@ -0,0 +1 @@ +Thank you for installing aspd. diff --git a/resources/plugins/ark/charts/aspd/templates/_helpers.tpl b/resources/plugins/ark/charts/aspd/templates/_helpers.tpl new file mode 100644 index 000000000..a699083e5 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/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/ark/charts/aspd/templates/deployment.yaml b/resources/plugins/ark/charts/aspd/templates/deployment.yaml new file mode 100644 index 000000000..ae7432877 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/templates/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-{{ .Values.name }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }}-{{ .Values.name }} + template: + metadata: + labels: + app: {{ .Release.Name }}-{{ .Values.name }} + spec: + containers: + - name: {{ .Values.name }}-main + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + command: ["aspd", "start", "--datadir", "{{ .Values.datadir }}"] + volumeMounts: + - name: data-volume + mountPath: "{{ .Values.datadir }}" + env: + - name: BITCOIND_URL + value: "{{ .Values.bitcoind.url }}" + - name: BITCOIND_COOKIE + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-bitcoind + key: cookie + initContainers: + - name: {{ .Values.name }}-setup + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + command: + [ + "aspd", "create", + "--network", "{{ .Values.network }}", + "--datadir", "{{ .Values.datadir }}", + "--bitcoind-url", "{{ .Values.bitcoind.url }}", + "--bitcoind-cookie", "$(BITCOIND_COOKIE)" + ] + env: + - name: BITCOIND_COOKIE + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-bitcoind + key: cookie + volumeMounts: + - name: data-volume + mountPath: "{{ .Values.datadir }}" + volumes: + - name: data-volume + emptyDir: {} diff --git a/resources/plugins/ark/charts/aspd/values.yaml b/resources/plugins/ark/charts/aspd/values.yaml new file mode 100644 index 000000000..d842fabf1 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/values.yaml @@ -0,0 +1,17 @@ +name: "aspd" + +image: + repository: "mplsgrant/aspd" + tag: "d1200b9" + pullPolicy: IfNotPresent + +network: regtest +datadir: /data/arkdatadir + +bitcoind: + url: http://bitcoind-url:port + cookie: bitcoind-cookie + +service: + type: ClusterIP + port: 3535 diff --git a/resources/plugins/ark/charts/bark/.helmignore b/resources/plugins/ark/charts/bark/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/plugins/ark/charts/bark/.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/ark/charts/bark/Chart.yaml b/resources/plugins/ark/charts/bark/Chart.yaml new file mode 100644 index 000000000..f190ce343 --- /dev/null +++ b/resources/plugins/ark/charts/bark/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: bark +description: A Helm chart to deploy bark +version: 0.1.0 +appVersion: alpha-git-tag diff --git a/resources/plugins/ark/charts/bark/templates/NOTES.txt b/resources/plugins/ark/charts/bark/templates/NOTES.txt new file mode 100644 index 000000000..435c9455b --- /dev/null +++ b/resources/plugins/ark/charts/bark/templates/NOTES.txt @@ -0,0 +1 @@ +Thank you for installing bark. diff --git a/resources/plugins/ark/charts/bark/templates/_helpers.tpl b/resources/plugins/ark/charts/bark/templates/_helpers.tpl new file mode 100644 index 000000000..a699083e5 --- /dev/null +++ b/resources/plugins/ark/charts/bark/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/ark/charts/bark/templates/pod.yaml b/resources/plugins/ark/charts/bark/templates/pod.yaml new file mode 100644 index 000000000..80f99e435 --- /dev/null +++ b/resources/plugins/ark/charts/bark/templates/pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "mychart.fullname" . }} + labels: + app: {{ include "mychart.name" . }} + mission: plugin +spec: + containers: + - name: {{ .Values.name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: {{ .Values.command | toJson }} + args: {{ .Values.args | toJson }} diff --git a/resources/plugins/ark/charts/bark/values.yaml b/resources/plugins/ark/charts/bark/values.yaml new file mode 100644 index 000000000..5a3f79cf2 --- /dev/null +++ b/resources/plugins/ark/charts/bark/values.yaml @@ -0,0 +1,7 @@ +name: "bark" +image: + repository: "mplsgrant/bark" + tag: "d1200b9" + pullPolicy: IfNotPresent +command: ["sh", "-c"] +args: ["while true; do sleep 3600; done"] diff --git a/resources/plugins/ark/plugin.yaml b/resources/plugins/ark/plugin.yaml new file mode 100644 index 000000000..d4ca94189 --- /dev/null +++ b/resources/plugins/ark/plugin.yaml @@ -0,0 +1 @@ +enabled: true From 3a2c08e68841a7ec3fc1f8bb752249e8904a0b1c Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:08 -0600 Subject: [PATCH 15/72] add demo plugin --- resources/plugins/demo_plugin/demo.py | 6 ++++++ resources/plugins/demo_plugin/plugin.yaml | 1 + 2 files changed, 7 insertions(+) create mode 100644 resources/plugins/demo_plugin/demo.py create mode 100644 resources/plugins/demo_plugin/plugin.yaml diff --git a/resources/plugins/demo_plugin/demo.py b/resources/plugins/demo_plugin/demo.py new file mode 100644 index 000000000..889da98eb --- /dev/null +++ b/resources/plugins/demo_plugin/demo.py @@ -0,0 +1,6 @@ +from hooks_api import pre_status + + +@pre_status +def print_something_first(): + print("The demo plug is enabled.") diff --git a/resources/plugins/demo_plugin/plugin.yaml b/resources/plugins/demo_plugin/plugin.yaml new file mode 100644 index 000000000..d4ca94189 --- /dev/null +++ b/resources/plugins/demo_plugin/plugin.yaml @@ -0,0 +1 @@ +enabled: true From fc253dc75c0be83fa771518df5948b2004c53240 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:26 -0600 Subject: [PATCH 16/72] add plugin "mission" --- src/warnet/constants.py | 1 + src/warnet/control.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index a575877f8..aab82d004 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" diff --git a/src/warnet/control.py b/src/warnet/control.py index 9318ae5c7..bb15aa40d 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -23,6 +23,7 @@ COMMANDER_CHART, COMMANDER_CONTAINER, COMMANDER_MISSION, + PLUGIN_MISSION, TANK_MISSION, ) from .hooks import api @@ -382,8 +383,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}") From 28d389e9eb57a2003750c44a0798f79ef93316d0 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:45 -0600 Subject: [PATCH 17/72] update hooks logic --- src/warnet/hooks.py | 85 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 2b595c63e..27e2977ee 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Optional import click +import yaml from warnet.constants import ( HOOK_NAME_KEY, @@ -16,12 +17,18 @@ WARNET_USER_DIR_ENV_VAR, ) + +class PluginError(Exception): + pass + + hook_registry: set[Callable[..., Any]] = set() imported_modules = {} @click.group(name="plugin") def plugin(): + """Control plugins""" pass @@ -33,6 +40,29 @@ def ls(): click.secho("Could not determine the plugin directory location.") click.secho("Consider setting environment variable containing your project directory:") click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") + sys.exit(1) + + 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") + + +@plugin.command() +@click.argument("plugin", type=str) +@click.argument("function", type=str) +def run(plugin: str, function: str): + module = imported_modules.get(f"plugins.{plugin}") + if hasattr(module, function): + func = getattr(module, function) + if callable(func): + result = func() + print(result) + else: + click.secho(f"{function} in {module} is not callable.") + else: + click.secho(f"Could not find {function} in {module}") def api(func: Callable[..., Any]) -> Callable[..., Any]: @@ -134,6 +164,11 @@ def load_user_modules() -> bool: 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)) @@ -146,15 +181,16 @@ def load_user_modules() -> bool: sys.modules[HOOKS_API_STEM] = hooks_module hooks_spec.loader.exec_module(hooks_module) - for file in plugin_dir.glob("*.py"): - if file.stem not in ("__init__", HOOKS_API_STEM): - 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 + for plugin_path in enabled_plugins: + for file in plugin_path.glob("*.py"): + if file.stem not in ("__init__", HOOKS_API_STEM): + 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) @@ -190,3 +226,34 @@ def get_version(package_name: str) -> str: except PackageNotFoundError: print(f"Package not found: {package_name}") sys.exit(1) + + +def open_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 check_if_plugin_enabled(path: Path) -> bool: + enabled = None + try: + plugin_dict = open_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: Path) -> list[tuple[Path, bool]]: + 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] From 71e217cc92af41cc0b542ede855cae3038b603f1 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Thu, 14 Nov 2024 15:21:37 -0500 Subject: [PATCH 18/72] ln: open rest api and ensure scenarios can open channels --- .../charts/lnd/templates/service.yaml | 4 ++ .../charts/bitcoincore/charts/lnd/values.yaml | 1 + resources/scenarios/commander.py | 59 +++++++++++++++++++ .../scenarios/test_scenarios/ln_basic.py | 44 ++++++++++++++ test/data/ln/network.yaml | 9 +-- test/ln_basic_test.py | 34 +++++++---- 6 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 resources/scenarios/test_scenarios/ln_basic.py diff --git a/resources/charts/bitcoincore/charts/lnd/templates/service.yaml b/resources/charts/bitcoincore/charts/lnd/templates/service.yaml index 6b2bc404e..51826ee9b 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/service.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/service.yaml @@ -16,5 +16,9 @@ spec: targetPort: p2p protocol: TCP name: p2p + - port: {{ .Values.RestPort }} + targetPort: rest + protocol: TCP + name: rest selector: {{- include "lnd.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml index b6a68a2da..e09cc37f6 100644 --- a/resources/charts/bitcoincore/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -115,6 +115,7 @@ affinity: {} baseConfig: | norest=false + restlisten=0.0.0.0:8080 debuglevel=debug accept-keysend=true bitcoin.active=true diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index 1f7d34a80..80f26be33 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -1,11 +1,14 @@ import argparse +import base64 import configparser +import http.client import json import logging import os import pathlib import random import signal +import ssl import sys import tempfile from typing import Dict @@ -22,6 +25,13 @@ WARNET_FILE = "/shared/warnet.json" +# hard-coded deterministic lnd credentials +ADMIN_MACAROON_HEX = "0201036c6e6402f801030a1062beabbf2a614b112128afa0c0b4fdd61201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620b17be53e367290871681055d0de15587f6d1cd47d1248fe2662ae27f62cfbdc6" +# Don't worry about lnd's self-signed certificates +INSECURE_CONTEXT = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) +INSECURE_CONTEXT.check_hostname = False +INSECURE_CONTEXT.verify_mode = ssl.CERT_NONE + try: with open(WARNET_FILE) as file: WARNET = json.load(file) @@ -39,6 +49,45 @@ def auth_proxy_request(self, method, path, postdata): AuthServiceProxy._request = auth_proxy_request +class LND: + def __init__(self, tank_name): + self.conn = http.client.HTTPSConnection( + host=f"{tank_name}-ln", port=8080, timeout=5, context=INSECURE_CONTEXT + ) + + def get(self, uri): + self.conn.request( + method="GET", url=uri, headers={"Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX} + ) + return self.conn.getresponse().read().decode("utf8") + + def post(self, uri, data): + body = json.dumps(data) + self.conn.request( + method="POST", + url=uri, + body=body, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(body)), + "Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX, + }, + ) + # Stream output, otherwise we get a timeout error + res = self.conn.getresponse() + stream = "" + while True: + try: + data = res.read(1) + if len(data) == 0: + break + else: + stream += data.decode("utf8") + except Exception: + break + return stream + + class Commander(BitcoinTestFramework): # required by subclasses of BitcoinTestFramework def set_test_params(self): @@ -55,6 +104,10 @@ def ensure_miner(node): node.createwallet("miner", descriptors=True) return node.get_wallet_rpc("miner") + @staticmethod + def hex_to_b64(hex): + return base64.b64encode(bytes.fromhex(hex)).decode() + def handle_sigterm(self, signum, frame): print("SIGTERM received, stopping...") self.shutdown() @@ -108,6 +161,12 @@ def setup(self): ) node.rpc_connected = True node.init_peers = tank["init_peers"] + + # Tank might not even have an ln node, that's + # not our problem, it'll just 404 if scenario tries + # to connect to it + node.lnd = LND(tank["tank"]) + self.nodes.append(node) self.tanks[tank["tank"]] = node diff --git a/resources/scenarios/test_scenarios/ln_basic.py b/resources/scenarios/test_scenarios/ln_basic.py new file mode 100644 index 000000000..413846241 --- /dev/null +++ b/resources/scenarios/test_scenarios/ln_basic.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +import json + +from commander import Commander + + +class LNBasic(Commander): + def set_test_params(self): + self.num_nodes = None + + def add_options(self, parser): + parser.description = "Open a channel between two LN nodes using REST + macaroon" + parser.usage = "warnet run /path/to/ln_init.py" + + def run_test(self): + info = json.loads(self.tanks["tank-0003"].lnd.get("/v1/getinfo")) + uri = info["uris"][0] + pk3, host = uri.split("@") + + print( + self.tanks["tank-0002"].lnd.post( + "/v1/peers", data={"addr": {"pubkey": pk3, "host": host}} + ) + ) + + print( + self.tanks["tank-0002"].lnd.post( + "/v1/channels/stream", + data={"local_funding_amount": 100000, "node_pubkey": self.hex_to_b64(pk3)}, + ) + ) + + # Mine it ourself + self.wait_until(lambda: self.tanks["tank-0002"].getmempoolinfo()["size"] == 1) + print(self.tanks["tank-0002"].generate(5, invalid_call=False)) + + +def main(): + LNBasic().main() + + +if __name__ == "__main__": + main() diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 4050dda72..d1c135242 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -51,11 +51,4 @@ nodes: addnode: - tank-0000 ln: - lnd: true - lnd: - channels: - - id: - block: 301 - index: 1 - target: tank-0000-ln - local_amt: 25000 + lnd: true \ No newline at end of file diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index d14955e40..20159cf2a 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -12,6 +12,7 @@ class LNBasicTest(TestBase): def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" + self.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" self.lns = [ "tank-0000-ln", "tank-0001-ln", @@ -29,16 +30,21 @@ def run_test(self): self.fund_wallets() # Manually open two channels between first three nodes - # and send a payment + # and send a payment using warnet RPC self.manual_open_channels() self.wait_for_gossip_sync(self.lns[:3], 2) self.pay_invoice(sender="tank-0000-ln", recipient="tank-0002-ln") - # Automatically open channels from network.yaml + # Automatically open channels from network.yaml using warnet RPC self.automatic_open_channels() - self.wait_for_gossip_sync(self.lns[3:], 3) + self.wait_for_gossip_sync(self.lns[3:], 2) # push_amt should enable payments from target to source self.pay_invoice(sender="tank-0005-ln", recipient="tank-0003-ln") + + # Automatically open channels from inside a scenario commander + self.scenario_open_channels() + self.pay_invoice(sender="tank-0002-ln", recipient="tank-0003-ln") + finally: self.cleanup() @@ -75,6 +81,11 @@ def fund_wallets(self): self.warnet("bitcoin rpc tank-0000 sendmany '' '{" + outputs + "}'") self.warnet("bitcoin rpc tank-0000 -generate 1") + def wait_for_two_txs(self): + self.wait_for_predicate( + lambda: json.loads(self.warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 2 + ) + def manual_open_channels(self): # 0 -> 1 -> 2 pk1 = self.warnet("ln pubkey tank-0001-ln") @@ -101,10 +112,7 @@ def manual_open_channels(self): ) ) - def wait_for_two_txs(): - return json.loads(self.warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 2 - - self.wait_for_predicate(wait_for_two_txs) + self.wait_for_two_txs() self.warnet("bitcoin rpc tank-0000 -generate 10") @@ -131,14 +139,20 @@ def wait_for_success(): self.wait_for_predicate(wait_for_success) def automatic_open_channels(self): + # 3 -> 4 -> 5 self.warnet("ln open-all-channels") - def wait_for_three_txs(): - return json.loads(self.warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 3 + self.wait_for_two_txs() - self.wait_for_predicate(wait_for_three_txs) self.warnet("bitcoin rpc tank-0000 -generate 10") + def scenario_open_channels(self): + # 2 -> 3 + # connecting all six ln nodes in the graph + scenario_file = self.scen_dir / "test_scenarios" / "ln_basic.py" + self.log.info(f"Running scenario from: {scenario_file}") + self.warnet(f"run {scenario_file} --source_dir={self.scen_dir} --debug") + if __name__ == "__main__": test = LNBasicTest() From 131689f54969a0bd1d4372affa6a113d6a3b6631 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 15 Nov 2024 13:55:55 -0500 Subject: [PATCH 19/72] use k8s client in commander to get pods instead of warnet.json --- resources/charts/commander/templates/pod.yaml | 3 +- .../charts/commander/templates/rbac.yaml | 35 +++++++++++++++++++ resources/images/commander/Dockerfile | 5 +++ resources/scenarios/commander.py | 33 ++++++++++++----- src/warnet/control.py | 19 ---------- 5 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 resources/charts/commander/templates/rbac.yaml create mode 100644 resources/images/commander/Dockerfile diff --git a/resources/charts/commander/templates/pod.yaml b/resources/charts/commander/templates/pod.yaml index 1a9bb9310..0ad4583e1 100644 --- a/resources/charts/commander/templates/pod.yaml +++ b/resources/charts/commander/templates/pod.yaml @@ -23,7 +23,7 @@ spec: mountPath: /shared containers: - name: {{ .Chart.Name }} - image: python:3.12-slim + image: bitcoindevproject/commander imagePullPolicy: IfNotPresent command: ["/bin/sh", "-c"] args: @@ -35,3 +35,4 @@ spec: volumes: - name: shared-volume emptyDir: {} + serviceAccountName: {{ include "commander.fullname" . }} \ No newline at end of file diff --git a/resources/charts/commander/templates/rbac.yaml b/resources/charts/commander/templates/rbac.yaml new file mode 100644 index 000000000..c7ef0fe76 --- /dev/null +++ b/resources/charts/commander/templates/rbac.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "commander.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "commander.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "commander.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "commander.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "commander.fullname" . }} + namespace: {{ .Release.Namespace }} diff --git a/resources/images/commander/Dockerfile b/resources/images/commander/Dockerfile new file mode 100644 index 000000000..4a4744717 --- /dev/null +++ b/resources/images/commander/Dockerfile @@ -0,0 +1,5 @@ +# Use an official Python runtime as the base image +FROM python:3.12-slim + +# Python dependencies +RUN pip install --no-cache-dir kubernetes diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index 80f26be33..d789164cd 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -13,6 +13,7 @@ import tempfile from typing import Dict +from kubernetes import client, config from test_framework.authproxy import AuthServiceProxy from test_framework.p2p import NetworkThread from test_framework.test_framework import ( @@ -23,8 +24,6 @@ from test_framework.test_node import TestNode from test_framework.util import PortSeed, get_rpc_proxy -WARNET_FILE = "/shared/warnet.json" - # hard-coded deterministic lnd credentials ADMIN_MACAROON_HEX = "0201036c6e6402f801030a1062beabbf2a614b112128afa0c0b4fdd61201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620b17be53e367290871681055d0de15587f6d1cd47d1248fe2662ae27f62cfbdc6" # Don't worry about lnd's self-signed certificates @@ -32,11 +31,30 @@ INSECURE_CONTEXT.check_hostname = False INSECURE_CONTEXT.verify_mode = ssl.CERT_NONE -try: - with open(WARNET_FILE) as file: - WARNET = json.load(file) -except Exception: - WARNET = [] +# Figure out what namespace we are in +with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f: + NAMESPACE = f.read().strip() + +# Use the in-cluster k8s client to determine what pods we have access to +config.load_incluster_config() +sclient = client.CoreV1Api() +pods = sclient.list_namespaced_pod(namespace=NAMESPACE) + +WARNET = [] +for pod in pods.items: + if "mission" not in pod.metadata.labels or pod.metadata.labels["mission"] != "tank": + continue + + WARNET.append( + { + "tank": pod.metadata.name, + "chain": pod.metadata.labels["chain"], + "rpc_host": pod.status.pod_ip, + "rpc_port": int(pod.metadata.labels["RPCPort"]), + "rpc_user": "user", + "rpc_password": pod.metadata.labels["rpcpassword"], + } + ) # Ensure that all RPC calls are made with brand new http connections @@ -160,7 +178,6 @@ def setup(self): coveragedir=self.options.coveragedir, ) node.rpc_connected = True - node.init_peers = tank["init_peers"] # Tank might not even have an ln node, that's # not our problem, it'll just 404 if scenario tries diff --git a/src/warnet/control.py b/src/warnet/control.py index 83d358a4e..15944704e 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -252,24 +252,7 @@ def run( if additional_args and ("--help" in additional_args or "-h" in additional_args): return subprocess.run([sys.executable, scenario_path, "--help"]) - # Collect tank data for warnet.json name = f"commander-{scenario_name.replace('_', '')}-{int(time.time())}" - tankpods = get_mission("tank") - tanks = [ - { - "tank": tank.metadata.name, - "chain": tank.metadata.labels["chain"], - "rpc_host": tank.status.pod_ip, - "rpc_port": int(tank.metadata.labels["RPCPort"]), - "rpc_user": "user", - "rpc_password": tank.metadata.labels["rpcpassword"], - "init_peers": [], - } - for tank in tankpods - ] - - # Encode tank data for warnet.json - warnet_data = json.dumps(tanks).encode() # Create in-memory buffer to store python archive instead of writing to disk archive_buffer = io.BytesIO() @@ -343,8 +326,6 @@ def filter(path): # upload scenario files and network data to the init container wait_for_init(name, namespace=namespace) if write_file_to_container( - name, "init", "/shared/warnet.json", warnet_data, namespace=namespace - ) and write_file_to_container( name, "init", "/shared/archive.pyz", archive_data, namespace=namespace ): print(f"Successfully uploaded scenario data to commander: {scenario_name}") From 11d88ca52ce4fb38e4f1966e2f81c7df71d995ae Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 15 Nov 2024 14:26:48 -0500 Subject: [PATCH 20/72] namespaces: give wargames accounts auth to create commander roles --- resources/charts/namespaces/values.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/charts/namespaces/values.yaml b/resources/charts/namespaces/values.yaml index 23ef66754..5a35da01e 100644 --- a/resources/charts/namespaces/values.yaml +++ b/resources/charts/namespaces/values.yaml @@ -33,7 +33,10 @@ roles: resources: ["pods/log", "pods/exec", "pods/attach", "pods/portforward"] verbs: ["get", "create"] - apiGroups: [""] - resources: ["configmaps", "secrets"] + resources: ["configmaps", "secrets", "serviceaccounts"] + verbs: ["get", "list", "create", "update"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] verbs: ["get", "list", "create", "update"] - apiGroups: [""] resources: ["persistentvolumeclaims", "namespaces"] From 98e209a36121346dcbca0d67c9ec6a647d28bc86 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 15 Nov 2024 15:46:57 -0500 Subject: [PATCH 21/72] add ln nodes and channels along with tanks to commander --- .../charts/commander/templates/rbac.yaml | 2 +- resources/scenarios/commander.py | 53 ++++++++++++------- .../scenarios/test_scenarios/ln_basic.py | 6 +-- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/resources/charts/commander/templates/rbac.yaml b/resources/charts/commander/templates/rbac.yaml index c7ef0fe76..7708328f3 100644 --- a/resources/charts/commander/templates/rbac.yaml +++ b/resources/charts/commander/templates/rbac.yaml @@ -15,7 +15,7 @@ metadata: app.kubernetes.io/name: {{ .Chart.Name }} rules: - apiGroups: [""] - resources: ["pods"] + resources: ["pods", "configmaps"] verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index d789164cd..ca7c16800 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -39,22 +39,35 @@ config.load_incluster_config() sclient = client.CoreV1Api() pods = sclient.list_namespaced_pod(namespace=NAMESPACE) +cmaps = sclient.list_namespaced_config_map(namespace=NAMESPACE) -WARNET = [] +WARNET = {"tanks": [], "lightning": [], "channels": []} for pod in pods.items: - if "mission" not in pod.metadata.labels or pod.metadata.labels["mission"] != "tank": + if "mission" not in pod.metadata.labels: continue - WARNET.append( - { - "tank": pod.metadata.name, - "chain": pod.metadata.labels["chain"], - "rpc_host": pod.status.pod_ip, - "rpc_port": int(pod.metadata.labels["RPCPort"]), - "rpc_user": "user", - "rpc_password": pod.metadata.labels["rpcpassword"], - } - ) + if pod.metadata.labels["mission"] == "tank": + WARNET["tanks"].append( + { + "tank": pod.metadata.name, + "chain": pod.metadata.labels["chain"], + "rpc_host": pod.status.pod_ip, + "rpc_port": int(pod.metadata.labels["RPCPort"]), + "rpc_user": "user", + "rpc_password": pod.metadata.labels["rpcpassword"], + } + ) + + if pod.metadata.labels["mission"] == "lightning": + WARNET["lightning"].append(pod.metadata.name) + +for cm in cmaps.items: + if not cm.metadata.labels or "channels" not in cm.metadata.labels: + continue + channel_jsons = json.loads(cm.data["channels"]) + for channel_json in channel_jsons: + channel_json["source"] = cm.data["source"] + WARNET["channels"].append(channel_json) # Ensure that all RPC calls are made with brand new http connections @@ -68,9 +81,9 @@ def auth_proxy_request(self, method, path, postdata): class LND: - def __init__(self, tank_name): + def __init__(self, pod_name): self.conn = http.client.HTTPSConnection( - host=f"{tank_name}-ln", port=8080, timeout=5, context=INSECURE_CONTEXT + host=pod_name, port=8080, timeout=5, context=INSECURE_CONTEXT ) def get(self, uri): @@ -153,8 +166,10 @@ def setup(self): # Keep a separate index of tanks by pod name self.tanks: Dict[str, TestNode] = {} + self.lns: Dict[str, LND] = {} + self.channels = WARNET["channels"] - for i, tank in enumerate(WARNET): + for i, tank in enumerate(WARNET["tanks"]): self.log.info( f"Adding TestNode #{i} from pod {tank['tank']} with IP {tank['rpc_host']}" ) @@ -179,14 +194,12 @@ def setup(self): ) node.rpc_connected = True - # Tank might not even have an ln node, that's - # not our problem, it'll just 404 if scenario tries - # to connect to it - node.lnd = LND(tank["tank"]) - self.nodes.append(node) self.tanks[tank["tank"]] = node + for ln in WARNET["lightning"]: + self.lns[ln] = LND(ln) + self.num_nodes = len(self.nodes) # Set up temp directory and start logging diff --git a/resources/scenarios/test_scenarios/ln_basic.py b/resources/scenarios/test_scenarios/ln_basic.py index 413846241..9eb46839e 100644 --- a/resources/scenarios/test_scenarios/ln_basic.py +++ b/resources/scenarios/test_scenarios/ln_basic.py @@ -14,18 +14,18 @@ def add_options(self, parser): parser.usage = "warnet run /path/to/ln_init.py" def run_test(self): - info = json.loads(self.tanks["tank-0003"].lnd.get("/v1/getinfo")) + info = json.loads(self.lns["tank-0003-ln"].get("/v1/getinfo")) uri = info["uris"][0] pk3, host = uri.split("@") print( - self.tanks["tank-0002"].lnd.post( + self.lns["tank-0002-ln"].post( "/v1/peers", data={"addr": {"pubkey": pk3, "host": host}} ) ) print( - self.tanks["tank-0002"].lnd.post( + self.lns["tank-0002-ln"].post( "/v1/channels/stream", data={"local_funding_amount": 100000, "node_pubkey": self.hex_to_b64(pk3)}, ) From e400b3da3866fa091f0addaa6a0c1b7ca7110a13 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 18 Nov 2024 09:37:12 -0500 Subject: [PATCH 22/72] minor: lint and remove duplicate config maps --- .../charts/bitcoincore/charts/lnd/templates/pod.yaml | 10 ++-------- resources/scenarios/test_scenarios/ln_basic.py | 4 +--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml index 351037e85..e3b9782d7 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml @@ -51,10 +51,10 @@ spec: name: config subPath: lnd.conf - mountPath: /root/.lnd/tls.key - name: tlskey + name: config subPath: tls.key - mountPath: /root/.lnd/tls.cert - name: tlscert + name: config subPath: tls.cert {{- if .Values.circuitBreaker }} - name: circuitbreaker @@ -68,12 +68,6 @@ spec: - configMap: name: {{ include "lnd.fullname" . }} name: config - - configMap: - name: {{ include "lnd.fullname" . }} - name: tlskey - - configMap: - name: {{ include "lnd.fullname" . }} - name: tlscert {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 4 }} diff --git a/resources/scenarios/test_scenarios/ln_basic.py b/resources/scenarios/test_scenarios/ln_basic.py index 9eb46839e..773ffd357 100644 --- a/resources/scenarios/test_scenarios/ln_basic.py +++ b/resources/scenarios/test_scenarios/ln_basic.py @@ -19,9 +19,7 @@ def run_test(self): pk3, host = uri.split("@") print( - self.lns["tank-0002-ln"].post( - "/v1/peers", data={"addr": {"pubkey": pk3, "host": host}} - ) + self.lns["tank-0002-ln"].post("/v1/peers", data={"addr": {"pubkey": pk3, "host": host}}) ) print( From fef2e678205e4e805c114ad2a677efd044670525 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 13:03:33 -0600 Subject: [PATCH 23/72] update namespace permissions This is an attempt to prevent the namespace test from failing. --- resources/charts/namespaces/values.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/charts/namespaces/values.yaml b/resources/charts/namespaces/values.yaml index 5a35da01e..b68480705 100644 --- a/resources/charts/namespaces/values.yaml +++ b/resources/charts/namespaces/values.yaml @@ -7,13 +7,13 @@ roles: - name: pod-viewer rules: - apiGroups: [""] - resources: ["pods", "services"] + resources: ["pods", "services", "configmaps"] verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["pods/log", "pods/exec", "pods/attach", "pods/portforward"] verbs: ["get"] - apiGroups: [""] - resources: ["configmaps", "secrets"] + resources: ["secrets"] verbs: ["get", "list"] - apiGroups: [""] resources: ["persistentvolumeclaims", "namespaces"] @@ -34,7 +34,7 @@ roles: verbs: ["get", "create"] - apiGroups: [""] resources: ["configmaps", "secrets", "serviceaccounts"] - verbs: ["get", "list", "create", "update"] + verbs: ["get", "list", "create", "update", "watch"] - apiGroups: ["rbac.authorization.k8s.io"] resources: ["roles", "rolebindings"] verbs: ["get", "list", "create", "update"] From 1f7567a53da1f2e48f22bc6a50857e9030ba2777 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 22 Nov 2024 15:30:46 -0500 Subject: [PATCH 24/72] ln_init: up through opening all channels --- .../charts/bitcoincore/charts/lnd/values.yaml | 4 +- resources/scenarios/commander.py | 104 +++- resources/scenarios/ln_init.py | 458 ++++++++++++------ src/warnet/graph.py | 5 +- 4 files changed, 396 insertions(+), 175 deletions(-) diff --git a/resources/charts/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml index e09cc37f6..d56e65bf4 100644 --- a/resources/charts/bitcoincore/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -83,9 +83,9 @@ readinessProbe: timeoutSeconds: 1 startupProbe: failureThreshold: 10 - periodSeconds: 10 + periodSeconds: 30 successThreshold: 1 - timeoutSeconds: 10 + timeoutSeconds: 60 exec: command: - /bin/sh diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index ca7c16800..c581c3fad 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -11,6 +11,7 @@ import ssl import sys import tempfile +import time from typing import Dict from kubernetes import client, config @@ -55,6 +56,7 @@ "rpc_port": int(pod.metadata.labels["RPCPort"]), "rpc_user": "user", "rpc_password": pod.metadata.labels["rpcpassword"], + "init_peers": pod.metadata.annotations["init_peers"], } ) @@ -82,41 +84,87 @@ def auth_proxy_request(self, method, path, postdata): class LND: def __init__(self, pod_name): + self.name = pod_name self.conn = http.client.HTTPSConnection( host=pod_name, port=8080, timeout=5, context=INSECURE_CONTEXT ) def get(self, uri): - self.conn.request( - method="GET", url=uri, headers={"Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX} - ) - return self.conn.getresponse().read().decode("utf8") + while True: + try: + self.conn.request( + method="GET", + url=uri, + headers={"Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX, "Connection": "close"}, + ) + return self.conn.getresponse().read().decode("utf8") + except Exception: + time.sleep(1) def post(self, uri, data): body = json.dumps(data) - self.conn.request( - method="POST", - url=uri, - body=body, - headers={ - "Content-Type": "application/json", - "Content-Length": str(len(body)), - "Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX, - }, - ) - # Stream output, otherwise we get a timeout error - res = self.conn.getresponse() - stream = "" + attempt = 0 while True: + attempt += 1 try: - data = res.read(1) - if len(data) == 0: - break - else: - stream += data.decode("utf8") + self.conn.request( + method="POST", + url=uri, + body=body, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(body)), + "Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX, + "Connection": "close", + }, + ) + # Stream output, otherwise we get a timeout error + res = self.conn.getresponse() + stream = "" + while True: + try: + data = res.read(1) + if len(data) == 0: + break + else: + stream += data.decode("utf8") + except Exception: + break + return stream except Exception: - break - return stream + time.sleep(1) + + def newaddress(self): + res = self.get("/v1/newaddress") + return json.loads(res) + + def walletbalance(self): + res = self.get("/v1/balance/blockchain") + return int(json.loads(res)["confirmed_balance"]) + + def uri(self): + res = self.get("/v1/getinfo") + info = json.loads(res) + if "uris" not in info or len(info["uris"]) == 0: + return None + return info["uris"][0] + + def connect(self, target_uri): + pk, host = target_uri.split("@") + res = self.post("/v1/peers", data={"addr": {"pubkey": pk, "host": host}}) + return json.loads(res) + + def channel(self, pk, local_amt, push_amt, fee_rate): + res = self.post( + "/v1/channels/stream", + data={ + "local_funding_amount": local_amt, + "push_sat": push_amt, + "node_pubkey": pk, + "sat_per_vbyte": fee_rate, + }, + ) + return json.loads(res) class Commander(BitcoinTestFramework): @@ -139,6 +187,13 @@ def ensure_miner(node): def hex_to_b64(hex): return base64.b64encode(bytes.fromhex(hex)).decode() + @staticmethod + def b64_to_hex(b64, reverse=False): + if reverse: + return base64.b64decode(b64)[::-1].hex() + else: + return base64.b64decode(b64).hex() + def handle_sigterm(self, signum, frame): print("SIGTERM received, stopping...") self.shutdown() @@ -193,6 +248,7 @@ def setup(self): coveragedir=self.options.coveragedir, ) node.rpc_connected = True + node.init_peers = int(tank["init_peers"]) self.nodes.append(node) self.tanks[tank["tank"]] = node diff --git a/resources/scenarios/ln_init.py b/resources/scenarios/ln_init.py index 82745a123..ba54146f2 100644 --- a/resources/scenarios/ln_init.py +++ b/resources/scenarios/ln_init.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import threading from time import sleep from commander import Commander @@ -14,171 +15,336 @@ def add_options(self, parser): parser.usage = "warnet run /path/to/ln_init.py" def run_test(self): - self.log.info("Lock out of IBD") - miner = self.ensure_miner(self.nodes[0]) - miner_addr = miner.getnewaddress() - self.generatetoaddress(self.nodes[0], 1, miner_addr, sync_fun=self.no_op) + ## + # L1 P2P + ## + self.log.info("Waiting for L1 p2p network connections...") - self.log.info("Get LN nodes and wallet addresses") - ln_nodes = [] - recv_addrs = [] - for tank in self.warnet.tanks: - if tank.lnnode is not None: - recv_addrs.append(tank.lnnode.getnewaddress()) - ln_nodes.append(tank.index) + def tank_connected(self, tank): + while True: + peers = tank.getpeerinfo() + count = sum( + 1 + for peer in peers + if peer.get("connection_type") == "manual" or peer.get("addnode") is True + ) + self.log.info(f"Tank {tank.tank} connected to {count}/{tank.init_peers} peers") + if count >= tank.init_peers: + break + else: + sleep(1) - self.log.info("Fund LN wallets") + conn_threads = [ + threading.Thread(target=tank_connected, args=(self, tank)) for tank in self.nodes + ] + for thread in conn_threads: + thread.start() + + all(thread.join() is None for thread in conn_threads) + self.log.info("Network connected") + + ## + # MINER + ## + self.log.info("Setting up miner...") miner = self.ensure_miner(self.nodes[0]) miner_addr = miner.getnewaddress() - # 298 block base - self.generatetoaddress(self.nodes[0], 297, miner_addr, sync_fun=self.no_op) - # divvy up the goods - split = (miner.getbalance() - 1) // len(recv_addrs) + + def gen(n): + return self.generatetoaddress(self.nodes[0], n, miner_addr, sync_fun=self.no_op) + + self.log.info("Locking out of IBD...") + gen(1) + + ## + # WALLET ADDRESSES + ## + self.log.info("Getting LN wallet addresses...") + ln_addrs = [] + + def get_ln_addr(self, name, ln): + while True: + res = ln.newaddress() + if "address" in res: + addr = res["address"] + ln_addrs.append(addr) + self.log.info(f"Got wallet address {addr} from {name}") + break + else: + self.log.info( + f"Couldn't get wallet address from {name}:\n {res}\n wait and retry..." + ) + sleep(1) + + addr_threads = [ + threading.Thread(target=get_ln_addr, args=(self, name, ln)) + for name, ln in self.lns.items() + ] + for thread in addr_threads: + thread.start() + + all(thread.join() is None for thread in addr_threads) + self.log.info(f"Got {len(ln_addrs)} addresses from {len(self.lns)} LN nodes") + + ## + # FUNDS + ## + self.log.info("Funding LN wallets...") + # 298 block base for miner wallet + gen(297) + # divvy up the goods, except fee. + # 10 UTXOs per node means 10 channel opens per node per block + split = (miner.getbalance() - 1) // len(ln_addrs) // 10 sends = {} - for addr in recv_addrs: - sends[addr] = split - miner.sendmany("", sends) + for _ in range(10): + for addr in ln_addrs: + sends[addr] = split + miner.sendmany("", sends) # confirm funds in block 299 - self.generatetoaddress(self.nodes[0], 1, miner_addr, sync_fun=self.no_op) + gen(1) self.log.info( - f"Waiting for funds to be spendable: {split} BTC each for {len(recv_addrs)} LN nodes" + f"Waiting for funds to be spendable: 10x{split} BTC UTXOs each for {len(ln_addrs)} LN nodes" ) - def funded_lnnodes(): - for tank in self.warnet.tanks: - if tank.lnnode is None: - continue - if int(tank.lnnode.get_wallet_balance()) < (split * 100000000): - return False - return True + def confirm_ln_balance(self, name, ln): + bal = 0 + while True: + bal = ln.walletbalance() + if bal >= (split * 100000000): + self.log.info(f"LN node {name} confirmed funds") + break + sleep(1) - self.wait_until(funded_lnnodes, timeout=5 * 60) + fund_threads = [ + threading.Thread(target=confirm_ln_balance, args=(self, name, ln)) + for name, ln in self.lns.items() + ] + for thread in fund_threads: + thread.start() - ln_nodes_uri = ln_nodes.copy() - while len(ln_nodes_uri) > 0: - self.log.info( - f"Waiting for all LN nodes to have URI, LN nodes remaining: {ln_nodes_uri}" - ) - for index in ln_nodes_uri: - lnnode = self.warnet.tanks[index].lnnode - if lnnode.getURI(): - ln_nodes_uri.remove(index) - sleep(5) - - self.log.info("Adding p2p connections to LN nodes") - for edge in self.warnet.graph.edges(data=True): - (src, dst, data) = edge - # Copy the L1 p2p topology (where applicable) to L2 - # so we get a more robust p2p graph for lightning - if ( - "channel_open" not in data - and self.warnet.tanks[src].lnnode - and self.warnet.tanks[dst].lnnode - ): - self.warnet.tanks[src].lnnode.connect_to_tank(dst) - - # Start confirming channel opens in block 300 - self.log.info("Opening channels, one per block") - chan_opens = [] - edges = self.warnet.graph.edges(data=True, keys=True) - edges = sorted(edges, key=lambda edge: edge[2]) - for edge in edges: - (src, dst, key, data) = edge - if "channel_open" in data: - src_node = self.warnet.get_ln_node_from_tank(src) - assert src_node is not None - assert self.warnet.get_ln_node_from_tank(dst) is not None - self.log.info(f"opening channel {src}->{dst}") - chan_pt = src_node.open_channel_to_tank(dst, data["channel_open"]) - # We can guarantee deterministic short channel IDs as long as - # the change output is greater than the channel funding output, - # which will then be output 0 - assert chan_pt[64:] == ":0" - chan_opens.append((edge, chan_pt)) - self.log.info(f" pending channel point: {chan_pt}") - self.wait_until( - lambda chan_pt=chan_pt: chan_pt[:64] in self.nodes[0].getrawmempool() - ) - self.generatetoaddress(self.nodes[0], 1, miner_addr) - assert chan_pt[:64] not in self.nodes[0].getrawmempool() - height = self.nodes[0].getblockcount() - self.log.info(f" confirmed in block {height}") + all(thread.join() is None for thread in fund_threads) + self.log.info("All LN nodes are funded") + + ## + # URIs + ## + self.log.info("Getting URIs for all LN nodes...") + ln_uris = {} + + def get_ln_uri(self, name, ln): + uri = None + while True: + uri = ln.uri() + if uri: + ln_uris[name] = uri + self.log.info(f"LN node {name} has URI {uri}") + break + sleep(1) + + uri_threads = [ + threading.Thread(target=get_ln_uri, args=(self, name, ln)) + for name, ln in self.lns.items() + ] + for thread in uri_threads: + thread.start() + + all(thread.join() is None for thread in uri_threads) + self.log.info("Got URIs from all LN nodes") + + ## + # P2P CONNECTIONS + ## + self.log.info("Adding p2p connections to LN nodes...") + # (source: LND, target_uri: str) tuples of LND instances + connections = [] + # Cycle graph through all LN nodes + nodes = list(self.lns.values()) + prev_node = nodes[-1] + for node in nodes: + connections.append((node, prev_node)) + prev_node = node + # Explicit connections between every pair of channel partners + for ch in self.channels: + src = self.lns[ch["source"]] + tgt = self.lns[ch["target"]] + # Avoid duplicates and reciprocals + if (src, tgt) not in connections and (tgt, src) not in connections: + connections.append((src, tgt)) + + def connect_ln(self, pair): + while True: + res = pair[0].connect(ln_uris[pair[1].name]) + if res == {}: + self.log.info(f"Connected LN nodes {pair[0].name} -> {pair[1].name}") + break + if "message" in res: + if "already connected" in res["message"]: + self.log.info( + f"Already connected LN nodes {pair[0].name} -> {pair[1].name}" + ) + break + if "process of starting" in res["message"]: + self.log.info( + f"{pair[0].name} not ready for connections yet, wait and retry..." + ) + sleep(1) + else: + self.log.info( + f"Unexpected response attempting to connect {pair[0].name} -> {pair[1].name}:\n {res}\n ABORTING" + ) + break + + p2p_threads = [ + threading.Thread(target=connect_ln, args=(self, pair)) for pair in connections + ] + for thread in p2p_threads: + thread.start() + + all(thread.join() is None for thread in p2p_threads) + self.log.info("Established all LN p2p connections") + + ## + # CHANNELS + ## + self.log.info("Opening lightning channels...") + # Sort the channels by assigned block and index + # so their channel ids are deterministic + ch_by_block = {} + for ch in self.channels: + # TODO: if "id" not in ch ... + block = ch["id"]["block"] + if block not in ch_by_block: + ch_by_block[block] = [ch] + else: + ch_by_block[block].append(ch) + blocks = list(ch_by_block.keys()) + blocks = sorted(blocks) + + for target_block in blocks: + # First make sure the target block is the next block + current_height = self.nodes[0].getblockcount() + need = target_block - current_height + if need < 1: + raise Exception("Blockchain too long for deterministic channel ID") + if need > 1: + gen(need - 1) + + def open_channel(self, ch, fee_rate): + src = self.lns[ch["source"]] + tgt_uri = ln_uris[ch["target"]] + tgt_pk, _ = tgt_uri.split("@") self.log.info( - f" channel_id should be: {int.from_bytes(height.to_bytes(3, 'big') + (1).to_bytes(3, 'big') + (0).to_bytes(2, 'big'), 'big')}" + f"Sending channel open from {ch['source']} -> {ch['target']} with fee_rate={fee_rate}" ) - - # Ensure all channel opens are sufficiently confirmed - self.generatetoaddress(self.nodes[0], 10, miner_addr, sync_fun=self.no_op) - ln_nodes_gossip = ln_nodes.copy() - while len(ln_nodes_gossip) > 0: - self.log.info(f"Waiting for graph gossip sync, LN nodes remaining: {ln_nodes_gossip}") - for index in ln_nodes_gossip: - lnnode = self.warnet.tanks[index].lnnode - count_channels = len(lnnode.get_graph_channels()) - count_graph_nodes = len(lnnode.get_graph_nodes()) - if count_channels == len(chan_opens) and count_graph_nodes == len(ln_nodes): - ln_nodes_gossip.remove(index) + res = src.channel( + pk=self.hex_to_b64(tgt_pk), + local_amt=ch["local_amt"], + push_amt=ch["push_amt"], + fee_rate=fee_rate, + ) + if "result" not in res: + self.log.info( + "Unexpected channel open response:\n " + + f"From {ch['source']} -> {ch['target']} fee_rate={fee_rate}\n " + + f"{res}" + ) else: + txid = self.b64_to_hex(res["result"]["chan_pending"]["txid"], reverse=True) + ch["txid"] = txid self.log.info( - f" node {index} not synced (channels: {count_channels}/{len(chan_opens)}, nodes: {count_graph_nodes}/{len(ln_nodes)})" + f"Channel open {ch['source']} -> {ch['target']}\n " + + f"outpoint={txid}:{res['result']['chan_pending']['output_index']}\n " + + f"expected channel id: {ch['id']}" ) - sleep(5) - - self.log.info("Updating channel policies") - for edge, chan_pt in chan_opens: - (src, dst, key, data) = edge - if "target_policy" in data: - target_node = self.warnet.get_ln_node_from_tank(dst) - target_node.update_channel_policy(chan_pt, data["target_policy"]) - if "source_policy" in data: - source_node = self.warnet.get_ln_node_from_tank(src) - source_node.update_channel_policy(chan_pt, data["source_policy"]) - - while True: - self.log.info("Waiting for all channel policies to match") - score = 0 - for tank_index, me in enumerate(ln_nodes): - you = (tank_index + 1) % len(ln_nodes) - my_channels = self.warnet.tanks[me].lnnode.get_graph_channels() - your_channels = self.warnet.tanks[you].lnnode.get_graph_channels() - match = True - for _chan_index, my_chan in enumerate(my_channels): - your_chan = [ - chan - for chan in your_channels - if chan.short_chan_id == my_chan.short_chan_id - ][0] - if not your_chan: - print(f"Channel policy missing for channel: {my_chan.short_chan_id}") - match = False - break - try: - if not my_chan.channel_match(your_chan): - print( - f"Channel policy doesn't match between tanks {me} & {you}: {my_chan.short_chan_id}" - ) - match = False - break - except Exception as e: - print(f"Error comparing channel policies: {e}") - print( - f"Channel policy doesn't match between tanks {me} & {you}: {my_chan.short_chan_id}" - ) - match = False - break - if match: - print(f"All channel policies match between tanks {me} & {you}") - score += 1 - print(f"Score: {score} / {len(ln_nodes)}") - if score == len(ln_nodes): - break - sleep(5) + channels = sorted(ch_by_block[target_block], key=lambda ch: ch["id"]["index"]) + index = 0 + fee_rate = 5006 # s/vB, decreases by 5 per tx for up to 1000 txs per block + ch_threads = [] + for ch in channels: + index += 1 # noqa + fee_rate -= 5 + assert index == ch["id"]["index"], "Channel ID indexes are not consecutive" + assert fee_rate >= 1, "Too many TXs in block, out of fee range" + t = threading.Thread(target=open_channel, args=(self, ch, fee_rate)) + t.start() + ch_threads.append(t) - self.log.info( - f"Warnet LN ready with {len(recv_addrs)} nodes and {len(chan_opens)} channels." - ) + all(thread.join() is None for thread in ch_threads) + self.log.info(f"Waiting for {len(channels)} channel opens in mempool...") + self.wait_until( + lambda channels=channels: self.nodes[0].getmempoolinfo()["size"] >= len(channels), + timeout=500, + ) + block_hash = gen(1)[0] + self.log.info(f"Confirmed {len(channels)} channel opens in block {target_block}") + self.log.info("Checking deterministic channel IDs in block...") + block = self.nodes[0].getblock(block_hash) + block_txs = block["tx"] + block_height = block["height"] + for ch in channels: + assert ch["id"]["block"] == block_height + assert block_txs[ch["id"]["index"]] == ch["txid"] + self.log.info("👍") + + gen(5) + self.log.info(f"Confirmed {len(self.channels)} total channel opens") + + # self.log.info("Updating channel policies") + # for edge, chan_pt in chan_opens: + # (src, dst, key, data) = edge + # if "target_policy" in data: + # target_node = self.warnet.get_ln_node_from_tank(dst) + # target_node.update_channel_policy(chan_pt, data["target_policy"]) + # if "source_policy" in data: + # source_node = self.warnet.get_ln_node_from_tank(src) + # source_node.update_channel_policy(chan_pt, data["source_policy"]) + + # while True: + # self.log.info("Waiting for all channel policies to match") + # score = 0 + # for tank_index, me in enumerate(ln_nodes): + # you = (tank_index + 1) % len(ln_nodes) + # my_channels = self.warnet.tanks[me].lnnode.get_graph_channels() + # your_channels = self.warnet.tanks[you].lnnode.get_graph_channels() + # match = True + # for _chan_index, my_chan in enumerate(my_channels): + # your_chan = [ + # chan + # for chan in your_channels + # if chan.short_chan_id == my_chan.short_chan_id + # ][0] + # if not your_chan: + # print(f"Channel policy missing for channel: {my_chan.short_chan_id}") + # match = False + # break + + # try: + # if not my_chan.channel_match(your_chan): + # print( + # f"Channel policy doesn't match between tanks {me} & {you}: {my_chan.short_chan_id}" + # ) + # match = False + # break + # except Exception as e: + # print(f"Error comparing channel policies: {e}") + # print( + # f"Channel policy doesn't match between tanks {me} & {you}: {my_chan.short_chan_id}" + # ) + # match = False + # break + # if match: + # print(f"All channel policies match between tanks {me} & {you}") + # score += 1 + # print(f"Score: {score} / {len(ln_nodes)}") + # if score == len(ln_nodes): + # break + # sleep(5) + + # self.log.info( + # f"Warnet LN ready with {len(recv_addrs)} nodes and {len(chan_opens)} channels." + # ) def main(): diff --git a/src/warnet/graph.py b/src/warnet/graph.py index 193b6d9f4..e10caff36 100644 --- a/src/warnet/graph.py +++ b/src/warnet/graph.py @@ -281,12 +281,11 @@ def import_policy(json_policy): count = 0 for edge in sorted_edges: source = pk_to_tank[edge["node1_pub"]] - amt = int(edge["capacity"]) // 2 channel = { "id": {"block": block, "index": index}, "target": pk_to_tank[edge["node2_pub"]] + "-ln", - "local_amt": amt, - "push_amt": amt - 1, + "local_amt": int(edge["capacity"]), + "push_amt": int(edge["capacity"]) // 2, "source_policy": import_policy(edge["node1_policy"]), "target_policy": import_policy(edge["node2_policy"]), } From 34cd1f9d7d6fe50a839187ba5c707527ce563548 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 01:50:56 -0600 Subject: [PATCH 25/72] Add the hook api --- resources/plugins/__init__.py | 0 resources/plugins/demo.py | 11 +++ src/warnet/constants.py | 6 ++ src/warnet/hooks.py | 140 ++++++++++++++++++++++++++++++++++ src/warnet/network.py | 13 ++++ src/warnet/project.py | 3 +- src/warnet/status.py | 2 + 7 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 resources/plugins/__init__.py create mode 100644 resources/plugins/demo.py create mode 100644 src/warnet/hooks.py diff --git a/resources/plugins/__init__.py b/resources/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/resources/plugins/demo.py b/resources/plugins/demo.py new file mode 100644 index 000000000..fad3c428e --- /dev/null +++ b/resources/plugins/demo.py @@ -0,0 +1,11 @@ +from hooks_api import post_status, pre_status + + +@pre_status +def print_something_wonderful(): + print("This has been a very pleasant day.") + + +@post_status +def print_something_afterwards(): + print("Status has run!") diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 104ff64b0..b02756c6f 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -37,6 +37,12 @@ NAMESPACES_FILE = "namespaces.yaml" DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" +# Plugin architecture +PLUGINS_DIR = RESOURCES_DIR.joinpath("plugins") +HOOK_NAME_KEY = "hook_name" # this lives as a key in object.__annotations__ +HOOKS_API_STEM = "hooks_api" +HOOKS_API_FILE = HOOKS_API_STEM + ".py" + # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) LND_CHART_LOCATION = str(CHARTS_DIR.joinpath("lnd")) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py new file mode 100644 index 000000000..9608766d2 --- /dev/null +++ b/src/warnet/hooks.py @@ -0,0 +1,140 @@ +import importlib.util +import inspect +import os +import sys +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any, Callable + +from warnet.constants import HOOK_NAME_KEY, HOOKS_API_FILE, HOOKS_API_STEM + +hook_registry: set[str] = set() +imported_modules = {} + + +def api(func: Callable[..., Any]) -> Callable[..., Any]: + """ + Functions with this decoration will have corresponding 'pre' and 'post' functions made + available to the user via the 'plugins' directory. + + Please ensure that @api is the innermost decorator: + + ```python + @click.command() # outermost + @api # innermost + def my_function(): + pass + ``` + """ + if func.__name__ in hook_registry: + print( + f"Cannot re-use function names in the Warnet plugin API -- " + f"'{func.__name__}' has already been taken." + ) + sys.exit(1) + hook_registry.add(func.__name__) + + if not imported_modules: + load_user_modules() + + pre_hooks, post_hooks = [], [] + for module_name in imported_modules: + pre, post = find_hooks(module_name, func.__name__) + pre_hooks.extend(pre) + post_hooks.extend(post) + + def wrapped(*args, **kwargs): + for hook in pre_hooks: + hook() + result = func(*args, **kwargs) + for hook in post_hooks: + hook() + return result + + # Mimic the base function; helps make `click` happy + wrapped.__name__ = func.__name__ + wrapped.__doc__ = func.__doc__ + + return wrapped + + +def create_hooks(directory: Path): + # Prepare directory and file + os.makedirs(directory, exist_ok=True) + init_file_path = os.path.join(directory, HOOKS_API_FILE) + + with open(init_file_path, "w") as file: + file.write(f"# API Version: {get_version('warnet')}") + # For each enum variant, create a corresponding decorator function + for hook in hook_registry: + file.write(decorator_code.format(hook=hook, HOOK_NAME_KEY=HOOK_NAME_KEY)) + + +decorator_code = """ + + +def pre_{hook}(func): + \"\"\"Functions with this decoration run before `{hook}`.\"\"\" + func.__annotations__['{HOOK_NAME_KEY}'] = 'pre_{hook}' + return func + + +def post_{hook}(func): + \"\"\"Functions with this decoration run after `{hook}`.\"\"\" + func.__annotations__['{HOOK_NAME_KEY}'] = 'post_{hook}' + return func +""" + + +def load_user_modules() -> bool: + was_successful_load = False + user_module_path = Path.cwd() / "plugins" + + if not user_module_path.is_dir(): + print("No plugins folder found in the current directory") + return was_successful_load + + # Temporarily add the current directory to sys.path for imports + sys.path.insert(0, str(Path.cwd())) + + hooks_path = user_module_path / HOOKS_API_FILE + if hooks_path.is_file(): + hooks_spec = importlib.util.spec_from_file_location(HOOKS_API_STEM, hooks_path) + hooks_module = importlib.util.module_from_spec(hooks_spec) + imported_modules[HOOKS_API_STEM] = hooks_module + sys.modules[HOOKS_API_STEM] = hooks_module + hooks_spec.loader.exec_module(hooks_module) + + for file in user_module_path.glob("*.py"): + if file.stem not in ("__init__", HOOKS_API_STEM): + module_name = f"plugins.{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 find_hooks(module_name: str, func_name: str): + module = imported_modules.get(module_name) + pre_hooks = [] + post_hooks = [] + for _, func in inspect.getmembers(module, inspect.isfunction): + if func.__annotations__.get(HOOK_NAME_KEY) == f"pre_{func_name}": + pre_hooks.append(func) + elif func.__annotations__.get(HOOK_NAME_KEY) == f"post_{func_name}": + post_hooks.append(func) + return pre_hooks, post_hooks + + +def get_version(package_name: str) -> str: + try: + return version(package_name) + except PackageNotFoundError: + print(f"Package not found: {package_name}") + sys.exit(1) diff --git a/src/warnet/network.py b/src/warnet/network.py index a894cafc9..0d677e7ce 100644 --- a/src/warnet/network.py +++ b/src/warnet/network.py @@ -7,8 +7,10 @@ from .bitcoin import _rpc from .constants import ( NETWORK_DIR, + PLUGINS_DIR, SCENARIOS_DIR, ) +from .hooks import create_hooks from .k8s import get_mission @@ -48,6 +50,17 @@ 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__"], + ) + create_hooks(directory / PLUGINS_DIR.name) + + 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..60ab3fef1 100644 --- a/src/warnet/status.py +++ b/src/warnet/status.py @@ -8,11 +8,13 @@ from rich.text import Text from urllib3.exceptions import MaxRetryError +from .hooks import api from .k8s import get_mission from .network import _connected @click.command() +@api def status(): """Display the unified status of the Warnet network and active scenarios""" console = Console() From 50c85216437f21393a7535f73492235d36ce8cc8 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 10:37:36 -0600 Subject: [PATCH 26/72] hooks: reduce the noise from the plugin check This text disrupts other commands whenever a `plugins` directory is not available. --- src/warnet/hooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 9608766d2..979345d1f 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -91,7 +91,6 @@ def load_user_modules() -> bool: user_module_path = Path.cwd() / "plugins" if not user_module_path.is_dir(): - print("No plugins folder found in the current directory") return was_successful_load # Temporarily add the current directory to sys.path for imports From e32eeb9d1e4c532df33862b25c1290a19163f34c Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 11:14:45 -0600 Subject: [PATCH 27/72] hooks: add func docs to hooks_api.py --- src/warnet/hooks.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 979345d1f..8984db911 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -8,7 +8,7 @@ from warnet.constants import HOOK_NAME_KEY, HOOKS_API_FILE, HOOKS_API_STEM -hook_registry: set[str] = set() +hook_registry: set[Callable[..., Any]] = set() imported_modules = {} @@ -26,13 +26,13 @@ def my_function(): pass ``` """ - if func.__name__ in hook_registry: + if func.__name__ in [fn.__name__ for fn in hook_registry]: print( f"Cannot re-use function names in the Warnet plugin API -- " f"'{func.__name__}' has already been taken." ) sys.exit(1) - hook_registry.add(func.__name__) + hook_registry.add(func) if not imported_modules: load_user_modules() @@ -66,21 +66,35 @@ def create_hooks(directory: Path): with open(init_file_path, "w") as file: file.write(f"# API Version: {get_version('warnet')}") # For each enum variant, create a corresponding decorator function - for hook in hook_registry: - file.write(decorator_code.format(hook=hook, HOOK_NAME_KEY=HOOK_NAME_KEY)) + for func in hook_registry: + file.write( + decorator_code.format( + hook=func.__name__, doc=func.__doc__, HOOK_NAME_KEY=HOOK_NAME_KEY + ) + ) decorator_code = """ def pre_{hook}(func): - \"\"\"Functions with this decoration run before `{hook}`.\"\"\" + \"\"\" + Functions with this decoration run before `{hook}`. + + `{hook}` documentation: + {doc} + \"\"\" func.__annotations__['{HOOK_NAME_KEY}'] = 'pre_{hook}' return func def post_{hook}(func): - \"\"\"Functions with this decoration run after `{hook}`.\"\"\" + \"\"\" + Functions with this decoration run after `{hook}`. + + `{hook}` documentation: + {doc} + \"\"\" func.__annotations__['{HOOK_NAME_KEY}'] = 'post_{hook}' return func """ From acee874ede82a6314b71448cec34d4e2a5a985bb Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 11:15:01 -0600 Subject: [PATCH 28/72] add @api to a number of functions --- src/warnet/control.py | 4 ++++ src/warnet/deploy.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/warnet/control.py b/src/warnet/control.py index 15944704e..9f65afcfc 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -25,6 +25,7 @@ COMMANDER_MISSION, TANK_MISSION, ) +from .hooks import api from .k8s import ( can_delete_pods, delete_pod, @@ -47,6 +48,7 @@ @click.command() @click.argument("scenario_name", required=False) +@api def stop(scenario_name): """Stop a running scenario or all scenarios""" active_scenarios = [sc.metadata.name for sc in get_mission("commander")] @@ -126,6 +128,7 @@ def stop_all_scenarios(scenarios): help="Skip confirmations", ) @click.command() +@api def down(force): """Bring down a running warnet quickly""" @@ -232,6 +235,7 @@ def get_active_network(namespace): ) @click.argument("additional_args", nargs=-1, type=click.UNPROCESSED) @click.option("--namespace", default=None, show_default=True) +@api def run( scenario_file: str, debug: bool, diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 59d8d057f..8579efcb1 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -22,6 +22,7 @@ NETWORK_FILE, WARGAMES_NAMESPACE_PREFIX, ) +from .hooks import api from .k8s import ( get_default_namespace, get_default_namespace_or, @@ -56,6 +57,7 @@ def validate_directory(ctx, param, value): @click.option("--namespace", type=str, help="Specify a namespace in which to deploy the network") @click.option("--to-all-users", is_flag=True, help="Deploy network to all user namespaces") @click.argument("unknown_args", nargs=-1) +@api def deploy(directory, debug, namespace, to_all_users, unknown_args): """Deploy a warnet with topology loaded from """ if unknown_args: From 0c9ca5d8434e3a63e960fe8838dc8af14b8c6c95 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 17:00:42 -0600 Subject: [PATCH 29/72] add rudimentary hooks test --- test/hooks_test.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100755 test/hooks_test.py 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() From 7cc9fb7c69cf9a8b810f76286016289afc7270fb Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 Nov 2024 17:02:11 -0600 Subject: [PATCH 30/72] update hooks commands --- src/warnet/constants.py | 4 ++- src/warnet/hooks.py | 57 ++++++++++++++++++++++++++++++++++------- src/warnet/main.py | 3 +++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index b02756c6f..6610b492a 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -38,10 +38,12 @@ DEFAULTS_NAMESPACE_FILE = "namespace-defaults.yaml" # Plugin architecture -PLUGINS_DIR = RESOURCES_DIR.joinpath("plugins") +PLUGINS_LABEL = "plugins" +PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_LABEL) HOOK_NAME_KEY = "hook_name" # this lives as a key in object.__annotations__ HOOKS_API_STEM = "hooks_api" HOOKS_API_FILE = HOOKS_API_STEM + ".py" +WARNET_USER_DIR_ENV_VAR = "WARNET_USER_DIR" # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 8984db911..2b595c63e 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -4,14 +4,37 @@ import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Optional -from warnet.constants import HOOK_NAME_KEY, HOOKS_API_FILE, HOOKS_API_STEM +import click + +from warnet.constants import ( + HOOK_NAME_KEY, + HOOKS_API_FILE, + HOOKS_API_STEM, + PLUGINS_LABEL, + WARNET_USER_DIR_ENV_VAR, +) hook_registry: set[Callable[..., Any]] = set() imported_modules = {} +@click.group(name="plugin") +def plugin(): + pass + + +@plugin.command() +def ls(): + plugin_dir = get_plugin_directory() + + if not plugin_dir: + click.secho("Could not determine the plugin directory location.") + click.secho("Consider setting environment variable containing your project directory:") + click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") + + def api(func: Callable[..., Any]) -> Callable[..., Any]: """ Functions with this decoration will have corresponding 'pre' and 'post' functions made @@ -73,6 +96,9 @@ def create_hooks(directory: Path): ) ) + click.secho("\nConsider setting environment variable containing your project directory:") + click.secho(f"export {WARNET_USER_DIR_ENV_VAR}={directory.parent}\n", fg="yellow") + decorator_code = """ @@ -102,15 +128,17 @@ def post_{hook}(func): def load_user_modules() -> bool: was_successful_load = False - user_module_path = Path.cwd() / "plugins" - if not user_module_path.is_dir(): + plugin_dir = get_plugin_directory() + + if not plugin_dir or not plugin_dir.is_dir(): return was_successful_load - # Temporarily add the current directory to sys.path for imports - sys.path.insert(0, str(Path.cwd())) + # Temporarily add the directory to sys.path for imports + sys.path.insert(0, str(plugin_dir)) + + hooks_path = plugin_dir / HOOKS_API_FILE - hooks_path = user_module_path / HOOKS_API_FILE if hooks_path.is_file(): hooks_spec = importlib.util.spec_from_file_location(HOOKS_API_STEM, hooks_path) hooks_module = importlib.util.module_from_spec(hooks_spec) @@ -118,9 +146,9 @@ def load_user_modules() -> bool: sys.modules[HOOKS_API_STEM] = hooks_module hooks_spec.loader.exec_module(hooks_module) - for file in user_module_path.glob("*.py"): + for file in plugin_dir.glob("*.py"): if file.stem not in ("__init__", HOOKS_API_STEM): - module_name = f"plugins.{file.stem}" + 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 @@ -145,6 +173,17 @@ def find_hooks(module_name: str, func_name: str): return pre_hooks, post_hooks +def get_plugin_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 get_version(package_name: str) -> str: try: return version(package_name) diff --git a/src/warnet/main.py b/src/warnet/main.py index 868147748..2e6776201 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -6,6 +6,8 @@ from .dashboard import dashboard from .deploy import deploy from .graph import create, graph, import_network +from .graph import create, graph +from .hooks import plugin from .image import image from .ln import ln from .project import init, new, setup @@ -37,6 +39,7 @@ def cli(): cli.add_command(status) cli.add_command(stop) cli.add_command(create) +cli.add_command(plugin) if __name__ == "__main__": From 097ea4a8fa32849efea33d9d0524ee3c0a891818 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:03 -0600 Subject: [PATCH 31/72] add ark plugin --- resources/plugins/ark/ark.py | 15 ++++++ resources/plugins/ark/charts/aspd/.helmignore | 23 ++++++++ resources/plugins/ark/charts/aspd/Chart.yaml | 5 ++ .../ark/charts/aspd/templates/NOTES.txt | 1 + .../ark/charts/aspd/templates/_helpers.tpl | 7 +++ .../ark/charts/aspd/templates/deployment.yaml | 52 +++++++++++++++++++ resources/plugins/ark/charts/aspd/values.yaml | 17 ++++++ resources/plugins/ark/charts/bark/.helmignore | 23 ++++++++ resources/plugins/ark/charts/bark/Chart.yaml | 5 ++ .../ark/charts/bark/templates/NOTES.txt | 1 + .../ark/charts/bark/templates/_helpers.tpl | 7 +++ .../ark/charts/bark/templates/pod.yaml | 14 +++++ resources/plugins/ark/charts/bark/values.yaml | 7 +++ resources/plugins/ark/plugin.yaml | 1 + 14 files changed, 178 insertions(+) create mode 100644 resources/plugins/ark/ark.py create mode 100644 resources/plugins/ark/charts/aspd/.helmignore create mode 100644 resources/plugins/ark/charts/aspd/Chart.yaml create mode 100644 resources/plugins/ark/charts/aspd/templates/NOTES.txt create mode 100644 resources/plugins/ark/charts/aspd/templates/_helpers.tpl create mode 100644 resources/plugins/ark/charts/aspd/templates/deployment.yaml create mode 100644 resources/plugins/ark/charts/aspd/values.yaml create mode 100644 resources/plugins/ark/charts/bark/.helmignore create mode 100644 resources/plugins/ark/charts/bark/Chart.yaml create mode 100644 resources/plugins/ark/charts/bark/templates/NOTES.txt create mode 100644 resources/plugins/ark/charts/bark/templates/_helpers.tpl create mode 100644 resources/plugins/ark/charts/bark/templates/pod.yaml create mode 100644 resources/plugins/ark/charts/bark/values.yaml create mode 100644 resources/plugins/ark/plugin.yaml diff --git a/resources/plugins/ark/ark.py b/resources/plugins/ark/ark.py new file mode 100644 index 000000000..d99292621 --- /dev/null +++ b/resources/plugins/ark/ark.py @@ -0,0 +1,15 @@ +from hooks_api import post_status, pre_status + + +@pre_status +def print_something_first(): + print("The ark plugin is enabled.") + + +@post_status +def print_something_afterwards(): + print("The ark plugin executes after `status` has run.") + + +def run(): + print("Running the ark plugin") diff --git a/resources/plugins/ark/charts/aspd/.helmignore b/resources/plugins/ark/charts/aspd/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/.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/ark/charts/aspd/Chart.yaml b/resources/plugins/ark/charts/aspd/Chart.yaml new file mode 100644 index 000000000..ec1dfaf3d --- /dev/null +++ b/resources/plugins/ark/charts/aspd/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: aspd +description: A Helm chart to deploy aspd +version: 0.1.0 +appVersion: "alpha-git-tag" diff --git a/resources/plugins/ark/charts/aspd/templates/NOTES.txt b/resources/plugins/ark/charts/aspd/templates/NOTES.txt new file mode 100644 index 000000000..d61b4f802 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/templates/NOTES.txt @@ -0,0 +1 @@ +Thank you for installing aspd. diff --git a/resources/plugins/ark/charts/aspd/templates/_helpers.tpl b/resources/plugins/ark/charts/aspd/templates/_helpers.tpl new file mode 100644 index 000000000..a699083e5 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/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/ark/charts/aspd/templates/deployment.yaml b/resources/plugins/ark/charts/aspd/templates/deployment.yaml new file mode 100644 index 000000000..ae7432877 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/templates/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-{{ .Values.name }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }}-{{ .Values.name }} + template: + metadata: + labels: + app: {{ .Release.Name }}-{{ .Values.name }} + spec: + containers: + - name: {{ .Values.name }}-main + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + command: ["aspd", "start", "--datadir", "{{ .Values.datadir }}"] + volumeMounts: + - name: data-volume + mountPath: "{{ .Values.datadir }}" + env: + - name: BITCOIND_URL + value: "{{ .Values.bitcoind.url }}" + - name: BITCOIND_COOKIE + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-bitcoind + key: cookie + initContainers: + - name: {{ .Values.name }}-setup + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + command: + [ + "aspd", "create", + "--network", "{{ .Values.network }}", + "--datadir", "{{ .Values.datadir }}", + "--bitcoind-url", "{{ .Values.bitcoind.url }}", + "--bitcoind-cookie", "$(BITCOIND_COOKIE)" + ] + env: + - name: BITCOIND_COOKIE + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-bitcoind + key: cookie + volumeMounts: + - name: data-volume + mountPath: "{{ .Values.datadir }}" + volumes: + - name: data-volume + emptyDir: {} diff --git a/resources/plugins/ark/charts/aspd/values.yaml b/resources/plugins/ark/charts/aspd/values.yaml new file mode 100644 index 000000000..d842fabf1 --- /dev/null +++ b/resources/plugins/ark/charts/aspd/values.yaml @@ -0,0 +1,17 @@ +name: "aspd" + +image: + repository: "mplsgrant/aspd" + tag: "d1200b9" + pullPolicy: IfNotPresent + +network: regtest +datadir: /data/arkdatadir + +bitcoind: + url: http://bitcoind-url:port + cookie: bitcoind-cookie + +service: + type: ClusterIP + port: 3535 diff --git a/resources/plugins/ark/charts/bark/.helmignore b/resources/plugins/ark/charts/bark/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/plugins/ark/charts/bark/.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/ark/charts/bark/Chart.yaml b/resources/plugins/ark/charts/bark/Chart.yaml new file mode 100644 index 000000000..f190ce343 --- /dev/null +++ b/resources/plugins/ark/charts/bark/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: bark +description: A Helm chart to deploy bark +version: 0.1.0 +appVersion: alpha-git-tag diff --git a/resources/plugins/ark/charts/bark/templates/NOTES.txt b/resources/plugins/ark/charts/bark/templates/NOTES.txt new file mode 100644 index 000000000..435c9455b --- /dev/null +++ b/resources/plugins/ark/charts/bark/templates/NOTES.txt @@ -0,0 +1 @@ +Thank you for installing bark. diff --git a/resources/plugins/ark/charts/bark/templates/_helpers.tpl b/resources/plugins/ark/charts/bark/templates/_helpers.tpl new file mode 100644 index 000000000..a699083e5 --- /dev/null +++ b/resources/plugins/ark/charts/bark/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/ark/charts/bark/templates/pod.yaml b/resources/plugins/ark/charts/bark/templates/pod.yaml new file mode 100644 index 000000000..80f99e435 --- /dev/null +++ b/resources/plugins/ark/charts/bark/templates/pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "mychart.fullname" . }} + labels: + app: {{ include "mychart.name" . }} + mission: plugin +spec: + containers: + - name: {{ .Values.name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: {{ .Values.command | toJson }} + args: {{ .Values.args | toJson }} diff --git a/resources/plugins/ark/charts/bark/values.yaml b/resources/plugins/ark/charts/bark/values.yaml new file mode 100644 index 000000000..5a3f79cf2 --- /dev/null +++ b/resources/plugins/ark/charts/bark/values.yaml @@ -0,0 +1,7 @@ +name: "bark" +image: + repository: "mplsgrant/bark" + tag: "d1200b9" + pullPolicy: IfNotPresent +command: ["sh", "-c"] +args: ["while true; do sleep 3600; done"] diff --git a/resources/plugins/ark/plugin.yaml b/resources/plugins/ark/plugin.yaml new file mode 100644 index 000000000..d4ca94189 --- /dev/null +++ b/resources/plugins/ark/plugin.yaml @@ -0,0 +1 @@ +enabled: true From 71341266e6e064aa9e5e423850bd19882e1b4745 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:08 -0600 Subject: [PATCH 32/72] add demo plugin --- resources/plugins/demo_plugin/demo.py | 6 ++++++ resources/plugins/demo_plugin/plugin.yaml | 1 + 2 files changed, 7 insertions(+) create mode 100644 resources/plugins/demo_plugin/demo.py create mode 100644 resources/plugins/demo_plugin/plugin.yaml diff --git a/resources/plugins/demo_plugin/demo.py b/resources/plugins/demo_plugin/demo.py new file mode 100644 index 000000000..889da98eb --- /dev/null +++ b/resources/plugins/demo_plugin/demo.py @@ -0,0 +1,6 @@ +from hooks_api import pre_status + + +@pre_status +def print_something_first(): + print("The demo plug is enabled.") diff --git a/resources/plugins/demo_plugin/plugin.yaml b/resources/plugins/demo_plugin/plugin.yaml new file mode 100644 index 000000000..d4ca94189 --- /dev/null +++ b/resources/plugins/demo_plugin/plugin.yaml @@ -0,0 +1 @@ +enabled: true From e9503fb4a5f01face0280164200859c3618bb48d Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:26 -0600 Subject: [PATCH 33/72] add plugin "mission" --- src/warnet/constants.py | 1 + src/warnet/control.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 6610b492a..1f35f2951 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" diff --git a/src/warnet/control.py b/src/warnet/control.py index 9f65afcfc..2949516ef 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -23,6 +23,7 @@ COMMANDER_CHART, COMMANDER_CONTAINER, COMMANDER_MISSION, + PLUGIN_MISSION, TANK_MISSION, ) from .hooks import api @@ -363,8 +364,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}") From 6b834227f68936b53eb1f78e8e4598b05ecfc546 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 14 Nov 2024 09:35:45 -0600 Subject: [PATCH 34/72] update hooks logic --- src/warnet/hooks.py | 85 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 2b595c63e..27e2977ee 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Optional import click +import yaml from warnet.constants import ( HOOK_NAME_KEY, @@ -16,12 +17,18 @@ WARNET_USER_DIR_ENV_VAR, ) + +class PluginError(Exception): + pass + + hook_registry: set[Callable[..., Any]] = set() imported_modules = {} @click.group(name="plugin") def plugin(): + """Control plugins""" pass @@ -33,6 +40,29 @@ def ls(): click.secho("Could not determine the plugin directory location.") click.secho("Consider setting environment variable containing your project directory:") click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") + sys.exit(1) + + 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") + + +@plugin.command() +@click.argument("plugin", type=str) +@click.argument("function", type=str) +def run(plugin: str, function: str): + module = imported_modules.get(f"plugins.{plugin}") + if hasattr(module, function): + func = getattr(module, function) + if callable(func): + result = func() + print(result) + else: + click.secho(f"{function} in {module} is not callable.") + else: + click.secho(f"Could not find {function} in {module}") def api(func: Callable[..., Any]) -> Callable[..., Any]: @@ -134,6 +164,11 @@ def load_user_modules() -> bool: 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)) @@ -146,15 +181,16 @@ def load_user_modules() -> bool: sys.modules[HOOKS_API_STEM] = hooks_module hooks_spec.loader.exec_module(hooks_module) - for file in plugin_dir.glob("*.py"): - if file.stem not in ("__init__", HOOKS_API_STEM): - 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 + for plugin_path in enabled_plugins: + for file in plugin_path.glob("*.py"): + if file.stem not in ("__init__", HOOKS_API_STEM): + 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) @@ -190,3 +226,34 @@ def get_version(package_name: str) -> str: except PackageNotFoundError: print(f"Package not found: {package_name}") sys.exit(1) + + +def open_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 check_if_plugin_enabled(path: Path) -> bool: + enabled = None + try: + plugin_dict = open_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: Path) -> list[tuple[Path, bool]]: + 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] From 737a0b1f73ba480cdc4b72081c726d980760d6db Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 18 Nov 2024 13:20:10 -0600 Subject: [PATCH 35/72] add simln charts --- .../plugins/simln/charts/simln/.helmignore | 23 +++++++++++ .../plugins/simln/charts/simln/Chart.yaml | 5 +++ .../simln/charts/simln/files/admin.macaroon | Bin 0 -> 293 bytes .../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 | 37 ++++++++++++++++++ .../plugins/simln/charts/simln/values.yaml | 8 ++++ resources/plugins/simln/plugin.yaml | 1 + resources/plugins/simln/simln.py | 15 +++++++ test/ln_basic_test.py | 2 + 12 files changed, 144 insertions(+) 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/admin.macaroon 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 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/admin.macaroon b/resources/plugins/simln/charts/simln/files/admin.macaroon new file mode 100644 index 0000000000000000000000000000000000000000..420d9d9e28c3fe424be6d5665115cd77ce97dcc4 GIT binary patch literal 293 zcmZQ#WX{P;Vfw+y%q5VtZ}onyL~lVwjr9u-Z25alh|xewjEg-nC8a2}xLAm#C^a!f zh_$>Zvm{kYn2RMdFD)NcP@Ib+Gp{T^GdUGawIUZsZens`QGR}&5J!4yUTRTdNh+#d zphdYrTN2aJ#DQk!r==xlBxdGeXvojQA}+$kT9BGrgysSXF1F&#bfCrP_A@ZBDQv8M jYG+n7pr@h literal 0 HcmV?d00001 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..a7e6fe4b6 --- /dev/null +++ b/resources/plugins/simln/charts/simln/files/sim.json @@ -0,0 +1,16 @@ +{ + "nodes": [ + { + "id": "tank-0000-ln", + "address": "https://tank-0000-ln:10009", + "macaroon": "/decoded/admin.macaroon", + "cert": "/config/tls.cert" + }, + { + "id": "tank-0001-ln", + "address": "https://tank-0001-ln:10009", + "macaroon": "/decoded/admin.macaroon", + "cert": "/config/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..6fe5ecc02 --- /dev/null +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "mychart.fullname" . }} + labels: + app: {{ include "mychart.name" . }} + mission: plugin +spec: + initContainers: + - name: decode-macaroon + image: busybox + command: ["sh", "-c"] + args: + - | + cat /config/admin.macaroon.hex | xxd -r -p > /decoded/admin.macaroon + volumeMounts: + - name: macaroon-volume + mountPath: /decoded + - name: config-volume + mountPath: /config + containers: + - name: {{ .Values.name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: {{ .Values.command | toJson }} + args: {{ .Values.args | toJson }} + volumeMounts: + - name: config-volume + mountPath: /config + - name: macaroon-volume + mountPath: /decoded + volumes: + - name: config-volume + configMap: + name: {{ include "mychart.fullname" . }}-data + - name: macaroon-volume + 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..ae8fa0182 --- /dev/null +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -0,0 +1,8 @@ +name: "simln" +image: + repository: "mplsgrant/sim-ln" + tag: "d8c165d" + pullPolicy: IfNotPresent +command: ["sh", "-c"] +args: ["while true; do sleep 3600; done"] +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..80e290a2b --- /dev/null +++ b/resources/plugins/simln/simln.py @@ -0,0 +1,15 @@ +from hooks_api import post_status, pre_status + + +@pre_status +def print_something_first(): + print("The simln plugin is enabled.") + + +@post_status +def print_something_afterwards(): + print("The simln plugin executes after `status` has run.") + + +def run(): + print("Running the simln plugin") diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index 20159cf2a..f32d93bd8 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -90,6 +90,8 @@ def manual_open_channels(self): # 0 -> 1 -> 2 pk1 = self.warnet("ln pubkey tank-0001-ln") pk2 = self.warnet("ln pubkey tank-0002-ln") + self.log.info(f"pk1: {pk1}") + self.log.info(f"pk2: {pk2}") host1 = "" host2 = "" From 4f9870a7a7f769eb29aa3b027d20873ca155e9c5 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 18 Nov 2024 13:34:59 -0600 Subject: [PATCH 36/72] fix config path --- .../plugins/simln/charts/simln/files/sim.json | 4 ++-- .../plugins/simln/charts/simln/templates/pod.yaml | 15 ++++++++------- src/warnet/main.py | 1 - 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/plugins/simln/charts/simln/files/sim.json b/resources/plugins/simln/charts/simln/files/sim.json index a7e6fe4b6..dd55be0f9 100644 --- a/resources/plugins/simln/charts/simln/files/sim.json +++ b/resources/plugins/simln/charts/simln/files/sim.json @@ -3,13 +3,13 @@ { "id": "tank-0000-ln", "address": "https://tank-0000-ln:10009", - "macaroon": "/decoded/admin.macaroon", + "macaroon": "/config/admin.macaroon", "cert": "/config/tls.cert" }, { "id": "tank-0001-ln", "address": "https://tank-0001-ln:10009", - "macaroon": "/decoded/admin.macaroon", + "macaroon": "/config/admin.macaroon", "cert": "/config/tls.cert" } ] diff --git a/resources/plugins/simln/charts/simln/templates/pod.yaml b/resources/plugins/simln/charts/simln/templates/pod.yaml index 6fe5ecc02..875abd4c7 100644 --- a/resources/plugins/simln/charts/simln/templates/pod.yaml +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -12,12 +12,13 @@ spec: command: ["sh", "-c"] args: - | - cat /config/admin.macaroon.hex | xxd -r -p > /decoded/admin.macaroon + cp /configmap/* /config + cat /config/admin.macaroon.hex | xxd -r -p > /config/admin.macaroon volumeMounts: - - name: macaroon-volume - mountPath: /decoded - name: config-volume mountPath: /config + - name: configmap-volume + mountPath: /configmap containers: - name: {{ .Values.name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -27,11 +28,11 @@ spec: volumeMounts: - name: config-volume mountPath: /config - - name: macaroon-volume - mountPath: /decoded + - name: configmap-volume + mountPath: /configmap volumes: - - name: config-volume + - name: configmap-volume configMap: name: {{ include "mychart.fullname" . }}-data - - name: macaroon-volume + - name: config-volume emptyDir: {} diff --git a/src/warnet/main.py b/src/warnet/main.py index 2e6776201..b74c9d66b 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -6,7 +6,6 @@ from .dashboard import dashboard from .deploy import deploy from .graph import create, graph, import_network -from .graph import create, graph from .hooks import plugin from .image import image from .ln import ln From 8ed897367bcd93aba092e84aaabc92ede26c4077 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 00:17:19 -0600 Subject: [PATCH 37/72] list and toggle plugins; clean up cruft --- resources/plugins/ark/plugin.yaml | 2 +- resources/plugins/demo.py | 11 ---- resources/plugins/demo_plugin/plugin.yaml | 2 +- .../simln/charts/simln/files/admin.macaroon | Bin 293 -> 0 bytes .../plugins/simln/charts/simln/files/sim.json | 4 +- src/warnet/hooks.py | 49 +++++++++++++++++- 6 files changed, 51 insertions(+), 17 deletions(-) delete mode 100644 resources/plugins/demo.py delete mode 100644 resources/plugins/simln/charts/simln/files/admin.macaroon diff --git a/resources/plugins/ark/plugin.yaml b/resources/plugins/ark/plugin.yaml index d4ca94189..bc114417c 100644 --- a/resources/plugins/ark/plugin.yaml +++ b/resources/plugins/ark/plugin.yaml @@ -1 +1 @@ -enabled: true +enabled: false diff --git a/resources/plugins/demo.py b/resources/plugins/demo.py deleted file mode 100644 index fad3c428e..000000000 --- a/resources/plugins/demo.py +++ /dev/null @@ -1,11 +0,0 @@ -from hooks_api import post_status, pre_status - - -@pre_status -def print_something_wonderful(): - print("This has been a very pleasant day.") - - -@post_status -def print_something_afterwards(): - print("Status has run!") diff --git a/resources/plugins/demo_plugin/plugin.yaml b/resources/plugins/demo_plugin/plugin.yaml index d4ca94189..bc114417c 100644 --- a/resources/plugins/demo_plugin/plugin.yaml +++ b/resources/plugins/demo_plugin/plugin.yaml @@ -1 +1 @@ -enabled: true +enabled: false diff --git a/resources/plugins/simln/charts/simln/files/admin.macaroon b/resources/plugins/simln/charts/simln/files/admin.macaroon deleted file mode 100644 index 420d9d9e28c3fe424be6d5665115cd77ce97dcc4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 293 zcmZQ#WX{P;Vfw+y%q5VtZ}onyL~lVwjr9u-Z25alh|xewjEg-nC8a2}xLAm#C^a!f zh_$>Zvm{kYn2RMdFD)NcP@Ib+Gp{T^GdUGawIUZsZens`QGR}&5J!4yUTRTdNh+#d zphdYrTN2aJ#DQk!r==xlBxdGeXvojQA}+$kT9BGrgysSXF1F&#bfCrP_A@ZBDQv8M jYG+n7pr@h diff --git a/resources/plugins/simln/charts/simln/files/sim.json b/resources/plugins/simln/charts/simln/files/sim.json index dd55be0f9..a02f142bf 100644 --- a/resources/plugins/simln/charts/simln/files/sim.json +++ b/resources/plugins/simln/charts/simln/files/sim.json @@ -2,13 +2,13 @@ "nodes": [ { "id": "tank-0000-ln", - "address": "https://tank-0000-ln:10009", + "address": "https://tank-0004-ln:10009", "macaroon": "/config/admin.macaroon", "cert": "/config/tls.cert" }, { "id": "tank-0001-ln", - "address": "https://tank-0001-ln:10009", + "address": "https://tank-0005-ln:10009", "macaroon": "/config/admin.macaroon", "cert": "/config/tls.cert" } diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 27e2977ee..200027672 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -1,13 +1,17 @@ +import copy import importlib.util import inspect import os import sys +import tempfile from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any, Callable, Optional import click +import inquirer import yaml +from inquirer.themes import GreenPassion from warnet.constants import ( HOOK_NAME_KEY, @@ -34,6 +38,7 @@ def plugin(): @plugin.command() def ls(): + """List all available plugins and whether they are activated.""" plugin_dir = get_plugin_directory() if not plugin_dir: @@ -49,6 +54,35 @@ def ls(): click.secho(f"{plugin.stem:<20} disabled", fg="yellow") +@plugin.command() +@click.argument("plugin", type=str, default="") +def toggle(plugin: str): + """Turn a plugin on or off""" + plugin_dir = get_plugin_directory() + + 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" + 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() + + 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")) + + @plugin.command() @click.argument("plugin", type=str) @click.argument("function", type=str) @@ -228,7 +262,7 @@ def get_version(package_name: str) -> str: sys.exit(1) -def open_yaml(path: Path) -> dict: +def read_yaml(path: Path) -> dict: try: with open(path) as file: return yaml.safe_load(file) @@ -238,10 +272,21 @@ def open_yaml(path: Path) -> dict: 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 = open_yaml(path / Path("plugin.yaml")) + plugin_dict = read_yaml(path / Path("plugin.yaml")) enabled = plugin_dict.get("enabled") except PluginError as e: click.secho(e) From d9b1fc7f7b45852650c399e51a0b69eac7560642 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 07:48:52 -0600 Subject: [PATCH 38/72] add get_pods_with_label --- src/warnet/k8s.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index d11214d04..b72535b84 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -545,3 +545,19 @@ 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 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 [] \ No newline at end of file From 6acaa1a33e401239531398a2295342c8d60f0ad9 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 07:49:11 -0600 Subject: [PATCH 39/72] update hooks and simln plugin --- .../plugins/simln/charts/simln/values.yaml | 2 +- resources/plugins/simln/simln.py | 232 +++++++++++++++++- src/warnet/hooks.py | 57 +++-- src/warnet/k8s.py | 2 +- 4 files changed, 267 insertions(+), 26 deletions(-) diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml index ae8fa0182..c8b3e5eb6 100644 --- a/resources/plugins/simln/charts/simln/values.yaml +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -4,5 +4,5 @@ image: tag: "d8c165d" pullPolicy: IfNotPresent command: ["sh", "-c"] -args: ["while true; do sleep 3600; done"] +args: ["cd /config; sim-cli"] defaultDataDir: /app/data diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 80e290a2b..47c06fa2a 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -1,15 +1,229 @@ -from hooks_api import post_status, pre_status +import json +import logging +import random +from pathlib import Path +from subprocess import run +from time import sleep +from warnet.hooks import _get_plugin_directory as get_plugin_directory +from warnet.k8s import get_pods_with_label +from warnet.process import run_command +from warnet.status import _get_tank_status as network_status -@pre_status -def print_something_first(): - print("The simln plugin is enabled.") +log = logging.getLogger("simln") +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) +lightning_selector = "mission=lightning" -@post_status -def print_something_afterwards(): - print("The simln plugin executes after `status` has run.") +# @post_deploy +def deploy_helm(): + print("SimLN Plugin ⚡") + plugin_dir = get_plugin_directory() + print(f"DIR: {plugin_dir}") + command = f"helm upgrade --install simln {plugin_dir}/simln/charts/simln" + helm_result = run_command(command) + print(helm_result) -def run(): - print("Running the simln plugin") + +def run_simln(): + init_network() + fund_wallets() + wait_for_predicate(everyone_has_a_host) + log.info(warnet("bitcoin rpc tank-0000 -generate 7")) + # warnet("ln open-all-channels") + manual_open_channels() + log.info(warnet("bitcoin rpc tank-0000 -generate 7")) + wait_for_gossip_sync(2) + log.info("done waiting") + pods = get_pods_with_label(lightning_selector) + pod_a = pods[0].metadata.name + pod_b = pods[1].metadata.name + sample_activity = [ + {"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000} + ] + log.info(f"Activity: {sample_activity}") + generate_activity(sample_activity) + log.info("Sent command. Done.") + + +def generate_activity(activity: list[dict]): + 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) + + +def init_network(): + 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_pods_with_label(lightning_selector) + 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) + + +def fund_wallets(): + log.info("Funding wallets") + outputs = "" + lns = get_pods_with_label(lightning_selector) + 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")) + + +def everyone_has_a_host() -> bool: + pods = get_pods_with_label(lightning_selector) + 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) + + +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="running", timeout=20 * 60, interval=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): + 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"] + log.info(f"{node}: {len(chs)} channels") + current += len(chs) + sleep(1) + log.info("Synced") + + +def warnet(cmd): + 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, output_file: Path = Path("nodes.json")): + nodes = [] + + for i in get_pods_with_label(lightning_selector): + name = i.metadata.name + node = { + "id": name, + "address": f"https://{name}:10009", + "macaroon": "/config/admin.macaroon", + "cert": "/config/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(): + 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") diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 200027672..a219a49ec 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -36,10 +36,19 @@ def plugin(): pass +@click.group(name="util") +def util(): + """Plugin utility functions""" + pass + + +plugin.add_command(util) + + @plugin.command() def ls(): - """List all available plugins and whether they are activated.""" - plugin_dir = get_plugin_directory() + """List all available plugins and whether they are activated""" + plugin_dir = _get_plugin_directory() if not plugin_dir: click.secho("Could not determine the plugin directory location.") @@ -57,8 +66,8 @@ def ls(): @plugin.command() @click.argument("plugin", type=str, default="") def toggle(plugin: str): - """Turn a plugin on or off""" - plugin_dir = get_plugin_directory() + """Toggle a plugin on or off""" + plugin_dir = _get_plugin_directory() if plugin == "": plugin_list = get_plugins_with_status(plugin_dir) @@ -67,15 +76,19 @@ def toggle(plugin: str): ] plugins_tag = "plugins" - 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() + 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) @@ -87,6 +100,15 @@ def toggle(plugin: str): @click.argument("plugin", type=str) @click.argument("function", type=str) def run(plugin: str, function: str): + """Run a command available in a plugin""" + plugin_dir = _get_plugin_directory() + plugins = get_plugins_with_status(plugin_dir) + for plugin_path, status in plugins: + if plugin_path.stem == plugin and not status: + click.secho(f"The plugin '{plugin_path.stem}' is not enabled", fg="yellow") + click.secho("Please toggle it on to run commands.") + sys.exit(0) + module = imported_modules.get(f"plugins.{plugin}") if hasattr(module, function): func = getattr(module, function) @@ -193,7 +215,7 @@ def post_{hook}(func): def load_user_modules() -> bool: was_successful_load = False - plugin_dir = get_plugin_directory() + plugin_dir = _get_plugin_directory() if not plugin_dir or not plugin_dir.is_dir(): return was_successful_load @@ -243,7 +265,7 @@ def find_hooks(module_name: str, func_name: str): return pre_hooks, post_hooks -def get_plugin_directory() -> Optional[Path]: +def _get_plugin_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 @@ -254,6 +276,11 @@ def get_plugin_directory() -> Optional[Path]: return None +@util.command() +def get_plugin_directory(): + click.secho(_get_plugin_directory()) + + def get_version(package_name: str) -> str: try: return version(package_name) diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index b72535b84..40c4bab3f 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -560,4 +560,4 @@ def get_pods_with_label(label_selector: str, namespace: Optional[str] = None) -> return v1_pods except client.exceptions.ApiException as e: print(f"Error fetching pods: {e}") - return [] \ No newline at end of file + return [] From 0485204789c577963a66af398ac5c2f2f1b9aca9 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 07:49:22 -0600 Subject: [PATCH 40/72] add test --- test/simln_test.py | 103 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100755 test/simln_test.py diff --git a/test/simln_test.py b/test/simln_test.py new file mode 100755 index 000000000..41688cf46 --- /dev/null +++ b/test/simln_test.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +import os +from pathlib import Path +from time import sleep + +import pexpect +from kubernetes.stream import stream +from test_base import TestBase + +from warnet.k8s import get_pods_with_label, get_static_client +from warnet.process import run_command + + +class LNTest(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.check_simln_logs() + result = self.copy_results() + assert result + 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.sut = pexpect.spawn("warnet init") + self.sut.expect("network", timeout=10) + self.sut.sendline("n") + + self.warnet("plugin run simln run_simln") + + def check_simln_logs(self): + pod = get_pods_with_label("mission=plugin") + self.log.info(run_command(f"kubectl logs pod/{pod.metadata.name}")) + + def copy_results(self) -> bool: + self.log.info("Copying results") + sleep(20) + pod = get_pods_with_label("mission=plugin")[0] + v1 = get_static_client() + + source_path = "/config/results" + destination_path = "results" + os.makedirs(destination_path, exist_ok=True) + command = ["tar", "cf", "-", source_path] + + # Create the stream + resp = stream( + v1.connect_get_namespaced_pod_exec, + name=pod.metadata.name, + namespace=pod.metadata.namespace, + command=command, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + + # Write the tar output to a file + tar_file = os.path.join(destination_path, "results.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() + + import tarfile + + with tarfile.open(tar_file, "r") as tar: + tar.extractall(path=destination_path) + + os.remove(tar_file) + + for root, _dirs, files in os.walk(destination_path): + 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: + return True + return False + + +if __name__ == "__main__": + test = LNTest() + test.run_test() From 990122e1f313c821ecb2900117b35974949eb095 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 18:10:00 -0600 Subject: [PATCH 41/72] k8s: make download fn; move get_pods_with_label --- src/warnet/k8s.py | 65 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 40c4bab3f..0c6b6f245 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] = [] @@ -547,17 +564,41 @@ def write_kubeconfig(kube_config: dict, kubeconfig_path: str) -> None: raise K8sError(f"Error writing kubeconfig: {kubeconfig_path}") from e -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) +def download(pod_name: str, namespace: str, source_path: Path, destination_path: Path = Path(".")): + """Download the item from the `source_path` to the `destination_path`""" + 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 [] + os.makedirs(destination_path, exist_ok=True) + target_folder = destination_path / source_path.stem + os.makedirs(target_folder, exist_ok=True) + + command = ["tar", "cf", "-", str(source_path)] + + 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=target_folder) + + os.remove(tar_file) From dae9ba1652a8e5cade0309349ad1fe3185eeb359 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 18:11:18 -0600 Subject: [PATCH 42/72] hooks: add args to `run` --- src/warnet/hooks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index a219a49ec..09c5084fd 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -99,7 +99,8 @@ def toggle(plugin: str): @plugin.command() @click.argument("plugin", type=str) @click.argument("function", type=str) -def run(plugin: str, function: str): +@click.argument("args", nargs=-1, type=str) # Accepts zero or more arguments +def run(plugin: str, function: str, args: tuple[str, ...]): """Run a command available in a plugin""" plugin_dir = _get_plugin_directory() plugins = get_plugins_with_status(plugin_dir) @@ -113,7 +114,7 @@ def run(plugin: str, function: str): if hasattr(module, function): func = getattr(module, function) if callable(func): - result = func() + result = func(*args) print(result) else: click.secho(f"{function} in {module} is not callable.") From 0677b92cf3257e931424b7228f9d2d1a670fb961 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 19 Nov 2024 18:11:29 -0600 Subject: [PATCH 43/72] update tests --- test/ln_test.py | 31 +++++++++++++++++++------------ test/simln_test.py | 41 ++--------------------------------------- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/test/ln_test.py b/test/ln_test.py index 8efe0b385..0a58104aa 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -7,6 +7,7 @@ from test_base import TestBase from warnet.cli.process import run_command +from warnet.k8s import get_pods_with_label class LNTest(TestBase): @@ -102,18 +103,24 @@ def test_ln_payment_2_to_0(self): payment["fee_msat"] == "2213" ), f"Expected fee_msat to be 2213, got {payment['fee_msat']}" - # def test_simln(self): - # self.log.info("Engaging simln") - # node2pub, _ = json.loads(self.warnet("ln rpc 2 getinfo"))["uris"][0].split("@") - # activity = [ - # {"source": "ln-0", "destination": node2pub, "interval_secs": 1, "amount_msat": 2000} - # ] - # self.warnet( - # f"network export --exclude=[1] --activity={json.dumps(activity).replace(' ', '')}" - # ) - # self.wait_for_predicate(lambda: self.check_invoices(2) > 1) - # assert self.check_invoices(0) == 1, "Expected one invoice for node 0" - # assert self.check_invoices(1) == 0, "Expected no invoices for node 1" + def test_simln(self): + self.log.info("Engaging simln") + pods = get_pods_with_label("mission=lightning") + node2pub = self.warnet(f"ln pubkey {pods[1].metadata.name}") + activity = [ + { + "source": pods[0].metadata.name, + "destination": node2pub, + "interval_secs": 1, + "amount_msat": 2000, + } + ] + self.warnet( + f"network export --exclude=[1] --activity={json.dumps(activity).replace(' ', '')}" + ) + self.wait_for_predicate(lambda: self.check_invoices(2) > 1) + assert self.check_invoices(0) == 1, "Expected one invoice for node 0" + assert self.check_invoices(1) == 0, "Expected no invoices for node 1" def check_invoice_settled(self): invs = json.loads(self.warnet("ln rpc tank-0002-ln listinvoices"))["invoices"] diff --git a/test/simln_test.py b/test/simln_test.py index 41688cf46..c72456509 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -5,10 +5,9 @@ from time import sleep import pexpect -from kubernetes.stream import stream from test_base import TestBase -from warnet.k8s import get_pods_with_label, get_static_client +from warnet.k8s import get_pods_with_label from warnet.process import run_command @@ -47,45 +46,9 @@ def check_simln_logs(self): def copy_results(self) -> bool: self.log.info("Copying results") sleep(20) - pod = get_pods_with_label("mission=plugin")[0] - v1 = get_static_client() + get_pods_with_label("mission=plugin")[0] - source_path = "/config/results" destination_path = "results" - os.makedirs(destination_path, exist_ok=True) - command = ["tar", "cf", "-", source_path] - - # Create the stream - resp = stream( - v1.connect_get_namespaced_pod_exec, - name=pod.metadata.name, - namespace=pod.metadata.namespace, - command=command, - stderr=True, - stdin=False, - stdout=True, - tty=False, - _preload_content=False, - ) - - # Write the tar output to a file - tar_file = os.path.join(destination_path, "results.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() - - import tarfile - - with tarfile.open(tar_file, "r") as tar: - tar.extractall(path=destination_path) - - os.remove(tar_file) for root, _dirs, files in os.walk(destination_path): for file_name in files: From eb02ecbd5f25a43bc651f8545d2dbcc67358ca68 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 00:30:24 -0600 Subject: [PATCH 44/72] clean up simln chart --- .../plugins/simln/charts/simln/files/sim.json | 8 ++-- .../simln/charts/simln/templates/pod.yaml | 44 +++++++++++-------- .../plugins/simln/charts/simln/values.yaml | 10 ++++- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/resources/plugins/simln/charts/simln/files/sim.json b/resources/plugins/simln/charts/simln/files/sim.json index a02f142bf..a72bd29e3 100644 --- a/resources/plugins/simln/charts/simln/files/sim.json +++ b/resources/plugins/simln/charts/simln/files/sim.json @@ -3,14 +3,14 @@ { "id": "tank-0000-ln", "address": "https://tank-0004-ln:10009", - "macaroon": "/config/admin.macaroon", - "cert": "/config/tls.cert" + "macaroon": "/working/admin.macaroon", + "cert": "/working/tls.cert" }, { "id": "tank-0001-ln", "address": "https://tank-0005-ln:10009", - "macaroon": "/config/admin.macaroon", - "cert": "/config/tls.cert" + "macaroon": "/working/admin.macaroon", + "cert": "/working/tls.cert" } ] } diff --git a/resources/plugins/simln/charts/simln/templates/pod.yaml b/resources/plugins/simln/charts/simln/templates/pod.yaml index 875abd4c7..bc92e8bc8 100644 --- a/resources/plugins/simln/charts/simln/templates/pod.yaml +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -7,32 +7,40 @@ metadata: mission: plugin spec: initContainers: - - name: decode-macaroon - image: busybox - command: ["sh", "-c"] + - name: "init-container" + image: "busybox" + command: + - "sh" + - "-c" args: - - | - cp /configmap/* /config - cat /config/admin.macaroon.hex | xxd -r -p > /config/admin.macaroon + - > + cp /configmap/* /working; + cd /working; + cat admin.macaroon.hex | xxd -r -p > admin.macaroon volumeMounts: - - name: config-volume - mountPath: /config - - name: configmap-volume - mountPath: /configmap + - 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: {{ .Values.command | toJson }} - args: {{ .Values.args | toJson }} + command: + - "sh" + - "-c" + args: + - > + cd /working; + sim-cli volumeMounts: - - name: config-volume - mountPath: /config - - name: configmap-volume - mountPath: /configmap + - name: {{ .Values.workingVolume.name }} + mountPath: {{ .Values.workingVolume.mountPath }} + - name: {{ .Values.configmapVolume.name }} + mountPath: {{ .Values.configmapVolume.mountPath }} volumes: - - name: configmap-volume + - name: {{ .Values.configmapVolume.name }} configMap: name: {{ include "mychart.fullname" . }}-data - - name: config-volume + - name: {{ .Values.workingVolume.name }} emptyDir: {} diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml index c8b3e5eb6..838f7a542 100644 --- a/resources/plugins/simln/charts/simln/values.yaml +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -3,6 +3,12 @@ image: repository: "mplsgrant/sim-ln" tag: "d8c165d" pullPolicy: IfNotPresent -command: ["sh", "-c"] -args: ["cd /config; sim-cli"] + +workingVolume: + name: working-volume + mountPath: /working +configmapVolume: + name: configmap-volume + mountPath: /configmap + defaultDataDir: /app/data From 9d0448e976daa793d514f2cd87210b20e6f95628 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 00:30:38 -0600 Subject: [PATCH 45/72] make plugin functionality discoverable And then show the user how to automate their discovery --- resources/plugins/simln/simln.py | 6 +- src/warnet/hooks.py | 123 +++++++++++++++++++++++++++---- test/simln_test.py | 31 ++++---- 3 files changed, 129 insertions(+), 31 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 47c06fa2a..d8539cc54 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -127,7 +127,7 @@ def wait_for_predicate(predicate, timeout=5 * 60, interval=5): ) -def wait_for_all_tanks_status(target="running", timeout=20 * 60, interval=5): +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 """ @@ -180,8 +180,8 @@ def generate_nodes_file(activity, output_file: Path = Path("nodes.json")): node = { "id": name, "address": f"https://{name}:10009", - "macaroon": "/config/admin.macaroon", - "cert": "/config/tls.cert", + "macaroon": "/working/admin.macaroon", + "cert": "/working/tls.cert", } nodes.append(node) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 09c5084fd..a9b671526 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -1,12 +1,14 @@ import copy import importlib.util import inspect +import json import os import sys import tempfile from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Any, Callable, Optional +from types import ModuleType +from typing import Any, Callable, Optional, Union, get_args, get_origin, get_type_hints import click import inquirer @@ -27,7 +29,7 @@ class PluginError(Exception): hook_registry: set[Callable[..., Any]] = set() -imported_modules = {} +imported_modules: dict[str, ModuleType] = {} @click.group(name="plugin") @@ -97,10 +99,11 @@ def toggle(plugin: str): @plugin.command() -@click.argument("plugin", type=str) -@click.argument("function", type=str) -@click.argument("args", nargs=-1, type=str) # Accepts zero or more arguments -def run(plugin: str, function: str, args: tuple[str, ...]): +@click.argument("plugin", type=str, default="") +@click.argument("function", type=str, default="") +@click.option("--args", default="", type=str, help="Apply positional arguments to the function") +@click.option("--json-dict", default="", type=str, help="Use json dict to populate parameters") +def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): """Run a command available in a plugin""" plugin_dir = _get_plugin_directory() plugins = get_plugins_with_status(plugin_dir) @@ -110,16 +113,106 @@ def run(plugin: str, function: str, args: tuple[str, ...]): click.secho("Please toggle it on to run commands.") sys.exit(0) - module = imported_modules.get(f"plugins.{plugin}") - if hasattr(module, function): - func = getattr(module, function) + if plugin == "": + plugin_names = [ + plugin_name.stem for plugin_name, status in get_plugins_with_status() if status + ] + + q = [inquirer.List(name="plugin", message="Please choose a plugin", choices=plugin_names)] + plugin = inquirer.prompt(q, theme=GreenPassion()).get("plugin") + + if function == "": + module = imported_modules.get(f"plugins.{plugin}") + funcs = [name for name, _func in inspect.getmembers(module, inspect.isfunction)] + q = [inquirer.List(name="func", message="Please choose a function", choices=funcs)] + function = inquirer.prompt(q, theme=GreenPassion()).get("func") + + func = get_func(function_name=function, plugin_name=plugin) + hints = get_type_hints(func) + if not func: + sys.exit(0) + + if args: + func(*args) + sys.exit(0) + + if not json_dict: + params = {} + sig = inspect.signature(func) + for name, param in sig.parameters.items(): + hint = hints.get(name) + hint_name = get_type_name(hint) + if param.default != inspect.Parameter.empty: + q = [ + inquirer.Text( + "input", + message=f"Enter a value for '{name}' ({hint_name})", + default=param.default, + ) + ] + else: + q = [ + inquirer.Text( + "input", + message=f"Enter a value for '{name}' ({hint_name})", + ) + ] + user_input = inquirer.prompt(q).get("input") + params[name] = cast_to_hint(user_input, hint) + click.secho( + f"\nwarnet plugin run {plugin} {function} --json-dict '{json.dumps(params)}'\n", + fg="green", + ) + else: + params = json.loads(json_dict) + + func(**params) + + +def cast_to_hint(value: str, hint: Any) -> Any: + """ + Cast a string value to the provided type hint. + """ + origin = get_origin(hint) + args = get_args(hint) + + # Handle basic types (int, str, float, etc.) + if origin is None: + return hint(value) + + # Handle Union (e.g., Union[int, str]) + if origin is Union: + for arg in args: + try: + return cast_to_hint(value, arg) + except (ValueError, TypeError): + continue + raise ValueError(f"Cannot cast {value} to {hint}") + + # Handle Lists (e.g., List[int]) + if origin is list: + return [cast_to_hint(v.strip(), args[0]) for v in value.split(",")] + + raise ValueError(f"Unsupported hint: {hint}") + + +def get_type_name(type_hint) -> str: + if hasattr(type_hint, "__name__"): + return type_hint.__name__ + return str(type_hint) + + +def get_func(function_name: str, plugin_name: str) -> Optional[Callable[..., Any]]: + module = imported_modules.get(f"plugins.{plugin_name}") + if hasattr(module, function_name): + func = getattr(module, function_name) if callable(func): - result = func(*args) - print(result) + return func else: - click.secho(f"{function} in {module} is not callable.") + click.secho(f"{function_name} in {module} is not callable.") else: - click.secho(f"Could not find {function} in {module}") + click.secho(f"Could not find {function_name} in {module}") + return None def api(func: Callable[..., Any]) -> Callable[..., Any]: @@ -322,7 +415,9 @@ def check_if_plugin_enabled(path: Path) -> bool: return bool(enabled) -def get_plugins_with_status(plugin_dir: Path) -> list[tuple[Path, bool]]: +def get_plugins_with_status(plugin_dir: Optional[Path] = None) -> list[tuple[Path, bool]]: + if not plugin_dir: + plugin_dir = _get_plugin_directory() 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 c72456509..24bb898bd 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -7,7 +7,7 @@ import pexpect from test_base import TestBase -from warnet.k8s import get_pods_with_label +from warnet.k8s import download, get_pods_with_label from warnet.process import run_command @@ -46,19 +46,22 @@ def check_simln_logs(self): def copy_results(self) -> bool: self.log.info("Copying results") sleep(20) - get_pods_with_label("mission=plugin")[0] - - destination_path = "results" - - for root, _dirs, files in os.walk(destination_path): - 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: - return True - return False + pod = get_pods_with_label("mission=plugin")[0] + + download( + pod.metadata.name, + pod.metadata.namespace, + ) + + # for root, _dirs, files in os.walk(destination_path): + # 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: + # return True + # return False if __name__ == "__main__": From 1dc7e8dd7bcb2f5fb3c0c1f84e969db1d72c09b9 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 00:57:31 -0600 Subject: [PATCH 46/72] clean up prompts --- src/warnet/hooks.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index a9b671526..34f790334 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -117,15 +117,21 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): plugin_names = [ plugin_name.stem for plugin_name, status in get_plugins_with_status() if status ] - q = [inquirer.List(name="plugin", message="Please choose a plugin", choices=plugin_names)] - plugin = inquirer.prompt(q, theme=GreenPassion()).get("plugin") + + plugin_answer = inquirer.prompt(q, theme=GreenPassion()) + if not plugin_answer: + sys.exit(0) + plugin = plugin_answer.get("plugin") if function == "": module = imported_modules.get(f"plugins.{plugin}") funcs = [name for name, _func in inspect.getmembers(module, inspect.isfunction)] q = [inquirer.List(name="func", message="Please choose a function", choices=funcs)] - function = inquirer.prompt(q, theme=GreenPassion()).get("func") + function_answer = inquirer.prompt(q, theme=GreenPassion()) + if not function_answer: + sys.exit(0) + function = function_answer.get("func") func = get_func(function_name=function, plugin_name=plugin) hints = get_type_hints(func) @@ -133,7 +139,11 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): sys.exit(0) if args: - func(*args) + try: + func(*args) + except Exception as e: + click.secho(f"Exception: {e}", fg="yellow") + sys.exit(1) sys.exit(0) if not json_dict: @@ -157,8 +167,15 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): message=f"Enter a value for '{name}' ({hint_name})", ) ] - user_input = inquirer.prompt(q).get("input") - params[name] = cast_to_hint(user_input, hint) + user_input_answer = inquirer.prompt(q) + if not user_input_answer: + sys.exit(0) + user_input = user_input_answer.get("input") + + if hint is None: + params[name] = user_input + else: + params[name] = cast_to_hint(user_input, hint) click.secho( f"\nwarnet plugin run {plugin} {function} --json-dict '{json.dumps(params)}'\n", fg="green", @@ -166,7 +183,12 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): else: params = json.loads(json_dict) - func(**params) + try: + return_value = func(**params) + if return_value: + click.secho(return_value) + except Exception as e: + click.secho(f"Exception: {e}", fg="yellow") def cast_to_hint(value: str, hint: Any) -> Any: @@ -197,6 +219,8 @@ def cast_to_hint(value: str, hint: Any) -> Any: def get_type_name(type_hint) -> str: + if type_hint is None: + return "Unknown type" if hasattr(type_hint, "__name__"): return type_hint.__name__ return str(type_hint) From 0cb1163d6e1c34eb5c3fd67b3c01cdb08443862b Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 02:10:13 -0600 Subject: [PATCH 47/72] update simln test --- .github/workflows/test.yml | 1 + resources/plugins/simln/simln.py | 23 +++++++++++++++------ src/warnet/hooks.py | 14 +++++++++---- src/warnet/status.py | 2 -- test/simln_test.py | 34 +++++++++++++------------------- 5 files changed, 42 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e199ed4d..107023725 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,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/simln/simln.py b/resources/plugins/simln/simln.py index d8539cc54..d00ade3bb 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -34,13 +34,18 @@ def deploy_helm(): def run_simln(): init_network() fund_wallets() - wait_for_predicate(everyone_has_a_host) + wait_for_everyone_to_have_a_host() log.info(warnet("bitcoin rpc tank-0000 -generate 7")) # warnet("ln open-all-channels") 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) + + +def prepare_and_launch_activity() -> str: pods = get_pods_with_label(lightning_selector) pod_a = pods[0].metadata.name pod_b = pods[1].metadata.name @@ -48,17 +53,19 @@ def run_simln(): {"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000} ] log.info(f"Activity: {sample_activity}") - generate_activity(sample_activity) + pod_name = launch_activity(sample_activity) log.info("Sent command. Done.") + return pod_name -def generate_activity(activity: list[dict]): +def launch_activity(activity: list[dict]) -> str: 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"pod/simln-simln-{random_digits}" def init_network(): @@ -108,6 +115,10 @@ def everyone_has_a_host() -> bool: return host_havers == len(pods) +def wait_for_everyone_to_have_a_host(): + wait_for_predicate(everyone_has_a_host) + + 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" @@ -148,7 +159,7 @@ def check_status(): wait_for_predicate(check_status, timeout, interval) -def wait_for_gossip_sync(expected): +def wait_for_gossip_sync(expected: int): log.info(f"Waiting for sync (expecting {expected})...") current = 0 while current < expected: @@ -163,7 +174,7 @@ def wait_for_gossip_sync(expected): log.info("Synced") -def warnet(cmd): +def warnet(cmd: str = "--help"): log.info(f"Executing warnet command: {cmd}") command = ["warnet"] + cmd.split() proc = run(command, capture_output=True) @@ -172,7 +183,7 @@ def warnet(cmd): return proc.stdout.decode() -def generate_nodes_file(activity, output_file: Path = Path("nodes.json")): +def generate_nodes_file(activity: dict, output_file: Path = Path("nodes.json")): nodes = [] for i in get_pods_with_label(lightning_selector): diff --git a/src/warnet/hooks.py b/src/warnet/hooks.py index 34f790334..cc15bc0b2 100644 --- a/src/warnet/hooks.py +++ b/src/warnet/hooks.py @@ -176,10 +176,16 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): params[name] = user_input else: params[name] = cast_to_hint(user_input, hint) - click.secho( - f"\nwarnet plugin run {plugin} {function} --json-dict '{json.dumps(params)}'\n", - fg="green", - ) + if not params: + click.secho( + f"\nwarnet plugin run {plugin} {function}\n", + fg="green", + ) + else: + click.secho( + f"\nwarnet plugin run {plugin} {function} --json-dict '{json.dumps(params)}'\n", + fg="green", + ) else: params = json.loads(json_dict) diff --git a/src/warnet/status.py b/src/warnet/status.py index 60ab3fef1..df62ed2df 100644 --- a/src/warnet/status.py +++ b/src/warnet/status.py @@ -8,13 +8,11 @@ from rich.text import Text from urllib3.exceptions import MaxRetryError -from .hooks import api from .k8s import get_mission from .network import _connected @click.command() -@api def status(): """Display the unified status of the Warnet network and active scenarios""" console = Console() diff --git a/test/simln_test.py b/test/simln_test.py index 24bb898bd..2809dcd97 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -2,13 +2,13 @@ import os from pathlib import Path +from subprocess import run from time import sleep import pexpect from test_base import TestBase from warnet.k8s import download, get_pods_with_label -from warnet.process import run_command class LNTest(TestBase): @@ -21,7 +21,6 @@ def run_test(self): os.chdir(self.tmpdir) self.setup_network() self.run_plugin() - self.check_simln_logs() result = self.copy_results() assert result finally: @@ -36,32 +35,27 @@ def run_plugin(self): self.sut = pexpect.spawn("warnet init") self.sut.expect("network", timeout=10) self.sut.sendline("n") + self.sut.close() - self.warnet("plugin run simln run_simln") - - def check_simln_logs(self): - pod = get_pods_with_label("mission=plugin") - self.log.info(run_command(f"kubectl logs pod/{pod.metadata.name}")) + self.log.info(run(["warnet", "plugin", "run", "simln", "run_simln"])) + sleep(10) def copy_results(self) -> bool: self.log.info("Copying results") sleep(20) pod = get_pods_with_label("mission=plugin")[0] - download( - pod.metadata.name, - pod.metadata.namespace, - ) + download(pod.metadata.name, pod.metadata.namespace, Path("/working/results"), Path(".")) + + for root, _dirs, files in os.walk(Path("results")): + for file_name in files: + file_path = os.path.join(root, file_name) - # for root, _dirs, files in os.walk(destination_path): - # 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: - # return True - # return False + with open(file_path) as file: + content = file.read() + if "Success" in content: + return True + return False if __name__ == "__main__": From 14c50c319af7aa04864458a70c6f1e8c9a5a0b6b Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 12:03:51 -0600 Subject: [PATCH 48/72] improve test Sleep a bit to allow simln to process --- resources/plugins/simln/simln.py | 11 +++++++---- src/warnet/k8s.py | 1 - test/simln_test.py | 30 +++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index d00ade3bb..4d5967d60 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -6,7 +6,7 @@ from time import sleep from warnet.hooks import _get_plugin_directory as get_plugin_directory -from warnet.k8s import get_pods_with_label +from warnet.k8s import get_pods_with_label, wait_for_pod from warnet.process import run_command from warnet.status import _get_tank_status as network_status @@ -43,12 +43,15 @@ def run_simln(): log.info("done waiting") pod_name = prepare_and_launch_activity() log.info(pod_name) + wait_for_pod(pod_name, 60) def prepare_and_launch_activity() -> str: pods = get_pods_with_label(lightning_selector) - pod_a = pods[0].metadata.name - pod_b = pods[1].metadata.name + pod_a = pods[1].metadata.name + pod_b = pods[2].metadata.name + log.info(f"pod_a: {pod_a}") + log.info(f"pod_b: {pod_b}") sample_activity = [ {"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000} ] @@ -65,7 +68,7 @@ def launch_activity(activity: list[dict]) -> str: command = f"helm upgrade --install simln-{random_digits} {plugin_dir}/simln/charts/simln" log.info(f"generate activity: {command}") run_command(command) - return f"pod/simln-simln-{random_digits}" + return f"simln-simln-{random_digits}" def init_network(): diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 0c6b6f245..7153dd197 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -569,7 +569,6 @@ def download(pod_name: str, namespace: str, source_path: Path, destination_path: v1 = get_static_client() - os.makedirs(destination_path, exist_ok=True) target_folder = destination_path / source_path.stem os.makedirs(target_folder, exist_ok=True) diff --git a/test/simln_test.py b/test/simln_test.py index 2809dcd97..622174e9c 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 - +import json import os from pathlib import Path from subprocess import run @@ -8,7 +8,10 @@ import pexpect from test_base import TestBase -from warnet.k8s import download, get_pods_with_label +from warnet.k8s import download, get_pods_with_label, pod_log, wait_for_pod +from warnet.process import run_command + +lightning_selector = "mission=lightning" class LNTest(TestBase): @@ -42,8 +45,15 @@ def run_plugin(self): def copy_results(self) -> bool: self.log.info("Copying results") - sleep(20) pod = get_pods_with_label("mission=plugin")[0] + self.wait_for_gossip_sync(2) + wait_for_pod(pod.metadata.name, 60) + sleep(20) + + log_resp = pod_log(pod.metadata.name, "simln") + self.log.info(log_resp.data.decode("utf-8")) + self.log.info("Sleep to process results") + sleep(60) download(pod.metadata.name, pod.metadata.namespace, Path("/working/results"), Path(".")) @@ -57,6 +67,20 @@ def copy_results(self) -> bool: return True return False + 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") + if __name__ == "__main__": test = LNTest() From 585f33d3677bc7668796a12a9973f58ce9d6ef05 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 16:51:22 -0600 Subject: [PATCH 49/72] rename hooks -> plugin --- resources/plugins/simln/simln.py | 2 +- src/warnet/control.py | 2 +- src/warnet/deploy.py | 2 +- src/warnet/main.py | 2 +- src/warnet/network.py | 2 +- src/warnet/{hooks.py => plugin.py} | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename src/warnet/{hooks.py => plugin.py} (100%) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 4d5967d60..c8174143d 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -5,8 +5,8 @@ from subprocess import run from time import sleep -from warnet.hooks import _get_plugin_directory as get_plugin_directory from warnet.k8s import get_pods_with_label, wait_for_pod +from warnet.plugin import _get_plugin_directory 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/control.py b/src/warnet/control.py index 2949516ef..1df97b953 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -26,7 +26,6 @@ PLUGIN_MISSION, TANK_MISSION, ) -from .hooks import api from .k8s import ( can_delete_pods, delete_pod, @@ -42,6 +41,7 @@ wait_for_pod, write_file_to_container, ) +from .plugin import api from .process import run_command, stream_command console = Console() diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 8579efcb1..f053d2967 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -22,7 +22,6 @@ NETWORK_FILE, WARGAMES_NAMESPACE_PREFIX, ) -from .hooks import api from .k8s import ( get_default_namespace, get_default_namespace_or, @@ -31,6 +30,7 @@ wait_for_ingress_controller, wait_for_pod_ready, ) +from .plugin import api from .process import stream_command HINT = "\nAre you trying to run a scenario? See `warnet run --help`" diff --git a/src/warnet/main.py b/src/warnet/main.py index b74c9d66b..a064f6ade 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -6,9 +6,9 @@ from .dashboard import dashboard from .deploy import deploy from .graph import create, graph, import_network -from .hooks import plugin from .image import image from .ln import ln +from .plugin import plugin from .project import init, new, setup from .status import status from .users import auth diff --git a/src/warnet/network.py b/src/warnet/network.py index 0d677e7ce..4fa236efb 100644 --- a/src/warnet/network.py +++ b/src/warnet/network.py @@ -10,8 +10,8 @@ PLUGINS_DIR, SCENARIOS_DIR, ) -from .hooks import create_hooks from .k8s import get_mission +from .plugin import create_hooks def copy_defaults(directory: Path, target_subdir: str, source_path: Path, exclude_list: list[str]): diff --git a/src/warnet/hooks.py b/src/warnet/plugin.py similarity index 100% rename from src/warnet/hooks.py rename to src/warnet/plugin.py From 389310a9c166f43b9b8eae40c41e959a328c74d6 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 17:24:17 -0600 Subject: [PATCH 50/72] improve ergonomics --- resources/plugins/simln/simln.py | 2 +- src/warnet/plugin.py | 56 ++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index c8174143d..9d3ecdec6 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -115,7 +115,7 @@ def everyone_has_a_host() -> bool: result = warnet(f"ln host {name}") if len(result) > 1: host_havers += 1 - return host_havers == len(pods) + return host_havers == len(pods) and host_havers != 0 def wait_for_everyone_to_have_a_host(): diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index cc15bc0b2..8cdbaa573 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -51,12 +51,8 @@ def util(): def ls(): """List all available plugins and whether they are activated""" plugin_dir = _get_plugin_directory() - - if not plugin_dir: - click.secho("Could not determine the plugin directory location.") - click.secho("Consider setting environment variable containing your project directory:") - click.secho(f"export {WARNET_USER_DIR_ENV_VAR}=/home/user/path/to/project/", fg="yellow") - sys.exit(1) + if plugin_dir is None: + direct_user_to_plugin_directory_and_exit() for plugin, status in get_plugins_with_status(plugin_dir): if status: @@ -70,6 +66,8 @@ def ls(): def toggle(plugin: str): """Toggle a plugin on or off""" plugin_dir = _get_plugin_directory() + if plugin_dir is None: + direct_user_to_plugin_directory_and_exit() if plugin == "": plugin_list = get_plugins_with_status(plugin_dir) @@ -99,21 +97,27 @@ def toggle(plugin: str): @plugin.command() -@click.argument("plugin", type=str, default="") -@click.argument("function", type=str, default="") +@click.argument("plugin_name", type=str, default="") +@click.argument("function_name", type=str, default="") @click.option("--args", default="", type=str, help="Apply positional arguments to the function") @click.option("--json-dict", default="", type=str, help="Use json dict to populate parameters") -def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): +def run(plugin_name: str, function_name: str, args: tuple[str, ...], json_dict: str): """Run a command available in a plugin""" plugin_dir = _get_plugin_directory() + if plugin_dir is None: + direct_user_to_plugin_directory_and_exit() + + if not plugin_dir: + click.secho("\nConsider setting environment variable containing your project directory:") + sys.exit(0) plugins = get_plugins_with_status(plugin_dir) for plugin_path, status in plugins: - if plugin_path.stem == plugin and not status: + if plugin_path.stem == plugin_name and not status: click.secho(f"The plugin '{plugin_path.stem}' is not enabled", fg="yellow") click.secho("Please toggle it on to run commands.") sys.exit(0) - if plugin == "": + if plugin_name == "": plugin_names = [ plugin_name.stem for plugin_name, status in get_plugins_with_status() if status ] @@ -122,18 +126,18 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): plugin_answer = inquirer.prompt(q, theme=GreenPassion()) if not plugin_answer: sys.exit(0) - plugin = plugin_answer.get("plugin") + plugin_name = plugin_answer.get("plugin") - if function == "": - module = imported_modules.get(f"plugins.{plugin}") + if function_name == "": + module = imported_modules.get(f"plugins.{plugin_name}") funcs = [name for name, _func in inspect.getmembers(module, inspect.isfunction)] q = [inquirer.List(name="func", message="Please choose a function", choices=funcs)] function_answer = inquirer.prompt(q, theme=GreenPassion()) if not function_answer: sys.exit(0) - function = function_answer.get("func") + function_name = function_answer.get("func") - func = get_func(function_name=function, plugin_name=plugin) + func = get_func(function_name=function_name, plugin_name=plugin_name) hints = get_type_hints(func) if not func: sys.exit(0) @@ -178,12 +182,12 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): params[name] = cast_to_hint(user_input, hint) if not params: click.secho( - f"\nwarnet plugin run {plugin} {function}\n", + f"\nwarnet plugin run {plugin_name} {function_name}\n", fg="green", ) else: click.secho( - f"\nwarnet plugin run {plugin} {function} --json-dict '{json.dumps(params)}'\n", + f"\nwarnet plugin run {plugin_name} {function_name} --json-dict '{json.dumps(params)}'\n", fg="green", ) else: @@ -191,7 +195,7 @@ def run(plugin: str, function: str, args: tuple[str, ...], json_dict: str): try: return_value = func(**params) - if return_value: + if return_value is not None: click.secho(return_value) except Exception as e: click.secho(f"Exception: {e}", fg="yellow") @@ -306,7 +310,7 @@ def create_hooks(directory: Path): ) ) - click.secho("\nConsider setting environment variable containing your project directory:") + click.secho("\nConsider setting an environment variable containing your project directory:") click.secho(f"export {WARNET_USER_DIR_ENV_VAR}={directory.parent}\n", fg="yellow") @@ -400,6 +404,18 @@ def _get_plugin_directory() -> Optional[Path]: 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) + + @util.command() def get_plugin_directory(): click.secho(_get_plugin_directory()) From 20410b7e2d2e1df24792b62d8e14c0daecca4681 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 01:28:53 -0600 Subject: [PATCH 51/72] tighten up simln and plugin interaction --- .../simln/charts/simln/templates/pod.yaml | 2 +- resources/plugins/simln/simln.py | 74 ++++++----- src/warnet/k8s.py | 14 ++- src/warnet/plugin.py | 118 ++++++++++++++---- 4 files changed, 143 insertions(+), 65 deletions(-) diff --git a/resources/plugins/simln/charts/simln/templates/pod.yaml b/resources/plugins/simln/charts/simln/templates/pod.yaml index bc92e8bc8..c933769cc 100644 --- a/resources/plugins/simln/charts/simln/templates/pod.yaml +++ b/resources/plugins/simln/charts/simln/templates/pod.yaml @@ -4,7 +4,7 @@ metadata: name: {{ include "mychart.fullname" . }} labels: app: {{ include "mychart.name" . }} - mission: plugin + mission: {{ .Values.name }} spec: initContainers: - name: "init-container" diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 9d3ecdec6..402b571c9 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -5,7 +5,7 @@ from subprocess import run from time import sleep -from warnet.k8s import get_pods_with_label, wait_for_pod +from warnet.k8s import download, get_pods_with_label, wait_for_pod from warnet.plugin import _get_plugin_directory as get_plugin_directory from warnet.process import run_command from warnet.status import _get_tank_status as network_status @@ -21,17 +21,8 @@ lightning_selector = "mission=lightning" -# @post_deploy -def deploy_helm(): - print("SimLN Plugin ⚡") - plugin_dir = get_plugin_directory() - print(f"DIR: {plugin_dir}") - command = f"helm upgrade --install simln {plugin_dir}/simln/charts/simln" - helm_result = run_command(command) - print(helm_result) - - def run_simln(): + """Run a SimLN Plugin demo""" init_network() fund_wallets() wait_for_everyone_to_have_a_host() @@ -41,30 +32,32 @@ def run_simln(): log.info(warnet("bitcoin rpc tank-0000 -generate 7")) wait_for_gossip_sync(2) log.info("done waiting") - pod_name = prepare_and_launch_activity() + pod_name = _prepare_and_launch_activity() log.info(pod_name) wait_for_pod(pod_name, 60) -def prepare_and_launch_activity() -> str: - pods = get_pods_with_label(lightning_selector) - pod_a = pods[1].metadata.name - pod_b = pods[2].metadata.name - log.info(f"pod_a: {pod_a}") - log.info(f"pod_b: {pod_b}") - sample_activity = [ - {"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000} - ] +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]: + """Get an activity representing node 2 sending msat to node 3""" + pods = get_pods_with_label(lightning_selector) + pod_a = pods[1].metadata.name + pod_b = pods[2].metadata.name + return [{"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000}] + + 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")) + _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) @@ -72,12 +65,13 @@ def launch_activity(activity: list[dict]) -> str: 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) + _wait_for_predicate(lambda: int(warnet("bitcoin rpc tank-0000 getblockcount")) > 100) def wait_for_all_ln_rpc(): lns = get_pods_with_label(lightning_selector) @@ -90,10 +84,11 @@ def wait_for_all_ln_rpc(): return False return True - wait_for_predicate(wait_for_all_ln_rpc) + _wait_for_predicate(wait_for_all_ln_rpc) def fund_wallets(): + """Fund each ln node with 10 regtest coins.""" log.info("Funding wallets") outputs = "" lns = get_pods_with_label(lightning_selector) @@ -108,6 +103,7 @@ def fund_wallets(): def everyone_has_a_host() -> bool: + """Find out if each ln node has a host.""" pods = get_pods_with_label(lightning_selector) host_havers = 0 for pod in pods: @@ -119,10 +115,10 @@ def everyone_has_a_host() -> bool: def wait_for_everyone_to_have_a_host(): - wait_for_predicate(everyone_has_a_host) + _wait_for_predicate(everyone_has_a_host) -def wait_for_predicate(predicate, timeout=5 * 60, interval=5): +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" ) @@ -142,9 +138,7 @@ def wait_for_predicate(predicate, timeout=5 * 60, interval=5): 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 - """ + """Poll the warnet server for container status. Block until all tanks are running""" def check_status(): tanks = network_status() @@ -159,10 +153,11 @@ def check_status(): 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) + _wait_for_predicate(check_status, timeout, interval) -def wait_for_gossip_sync(expected: int): +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: @@ -178,6 +173,7 @@ def wait_for_gossip_sync(expected: int): 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) @@ -186,7 +182,7 @@ def warnet(cmd: str = "--help"): return proc.stdout.decode() -def generate_nodes_file(activity: dict, output_file: Path = Path("nodes.json")): +def _generate_nodes_file(activity: list[dict], output_file: Path = Path("nodes.json")): nodes = [] for i in get_pods_with_label(lightning_selector): @@ -206,8 +202,10 @@ def generate_nodes_file(activity: dict, output_file: Path = Path("nodes.json")): def manual_open_channels(): + """Manually open channels between ln nodes 1, 2, and 3""" + def wait_for_two_txs(): - wait_for_predicate( + _wait_for_predicate( lambda: json.loads(warnet("bitcoin rpc tank-0000 getmempoolinfo"))["size"] == 2 ) @@ -241,3 +239,13 @@ def wait_for_two_txs(): wait_for_two_txs() warnet("bitcoin rpc tank-0000 -generate 10") + + +def list_simln_podnames() -> list[str]: + """Get a list of simln pod names""" + return [pod.metadata.name for pod in get_pods_with_label("mission=simln")] + + +def download_results(pod_name: str): + """Download SimLN results to the current directory""" + download(pod_name, source_path=Path("/working/results")) diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 7153dd197..5737c79e2 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -564,15 +564,22 @@ def write_kubeconfig(kube_config: dict, kubeconfig_path: str) -> None: raise K8sError(f"Error writing kubeconfig: {kubeconfig_path}") from e -def download(pod_name: str, namespace: str, source_path: Path, destination_path: Path = Path(".")): +def download( + pod_name: str, + source_path: Path, + destination_path: Path = Path("."), + namespace: Optional[str] = None, +): """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", "-", str(source_path)] + command = ["tar", "cf", "-", "-C", str(source_path.parent), str(source_path.name)] resp = stream( v1.connect_get_namespaced_pod_exec, @@ -594,10 +601,9 @@ def download(pod_name: str, namespace: str, source_path: Path, destination_path: 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=target_folder) + tar.extractall(path=destination_path) os.remove(tar_file) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 8cdbaa573..3821bef8e 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -99,17 +99,30 @@ def toggle(plugin: str): @plugin.command() @click.argument("plugin_name", type=str, default="") @click.argument("function_name", type=str, default="") -@click.option("--args", default="", type=str, help="Apply positional arguments to the function") @click.option("--json-dict", default="", type=str, help="Use json dict to populate parameters") -def run(plugin_name: str, function_name: str, args: tuple[str, ...], json_dict: str): - """Run a command available in a plugin""" +def run(plugin_name: str, function_name: str, json_dict: str): + """Explore and run plugins""" + + show_explainer = False + plugin_dir = _get_plugin_directory() if plugin_dir is None: direct_user_to_plugin_directory_and_exit() + if not json_dict and not sys.stdin.isatty(): + # read from a pipe: $ echo "something" | warnet plugin run + json_dict = sys.stdin.read() + if not plugin_name or not function_name: + click.secho( + "You must specify a plugin name and function name when piping in data.", fg="yellow" + ) + click.secho("Alternative: warnet plugin run --json-dict YOUR_DATA_HERE") + sys.exit(1) + if not plugin_dir: click.secho("\nConsider setting environment variable containing your project directory:") sys.exit(0) + plugins = get_plugins_with_status(plugin_dir) for plugin_path, status in plugins: if plugin_path.stem == plugin_name and not status: @@ -117,7 +130,8 @@ def run(plugin_name: str, function_name: str, args: tuple[str, ...], json_dict: click.secho("Please toggle it on to run commands.") sys.exit(0) - if plugin_name == "": + if plugin_name == "" and sys.stdin.isatty(): + show_explainer = True plugin_names = [ plugin_name.stem for plugin_name, status in get_plugins_with_status() if status ] @@ -128,28 +142,26 @@ def run(plugin_name: str, function_name: str, args: tuple[str, ...], json_dict: sys.exit(0) plugin_name = plugin_answer.get("plugin") - if function_name == "": + if function_name == "" and sys.stdin.isatty(): + show_explainer = True module = imported_modules.get(f"plugins.{plugin_name}") - funcs = [name for name, _func in inspect.getmembers(module, inspect.isfunction)] + funcs = [ + format_func_with_docstring(func) + for _name, func in inspect.getmembers(module, inspect.isfunction) + if func.__module__ == "plugins." + plugin_name and not func.__name__.startswith("_") + ] q = [inquirer.List(name="func", message="Please choose a function", choices=funcs)] function_answer = inquirer.prompt(q, theme=GreenPassion()) if not function_answer: sys.exit(0) - function_name = function_answer.get("func") + function_name_with_doc = function_answer.get("func") + function_name = function_name_with_doc.split("(")[0].strip() func = get_func(function_name=function_name, plugin_name=plugin_name) hints = get_type_hints(func) if not func: sys.exit(0) - if args: - try: - func(*args) - except Exception as e: - click.secho(f"Exception: {e}", fg="yellow") - sys.exit(1) - sys.exit(0) - if not json_dict: params = {} sig = inspect.signature(func) @@ -180,27 +192,69 @@ def run(plugin_name: str, function_name: str, args: tuple[str, ...], json_dict: params[name] = user_input else: params[name] = cast_to_hint(user_input, hint) - if not params: - click.secho( - f"\nwarnet plugin run {plugin_name} {function_name}\n", - fg="green", - ) - else: - click.secho( - f"\nwarnet plugin run {plugin_name} {function_name} --json-dict '{json.dumps(params)}'\n", - fg="green", - ) + + if show_explainer: + if not params: + click.secho( + f"\nwarnet plugin run {plugin_name} {function_name}\n", + fg="green", + ) + else: + click.secho( + f"\nwarnet plugin run {plugin_name} {function_name} --json-dict '{json.dumps(params)}'", + fg="green", + ) + click.secho( + f"echo '{json.dumps(params)}' | warnet plugin run {plugin_name} {function_name}\n", + fg="green", + ) else: params = json.loads(json_dict) try: - return_value = func(**params) + processed_params = process_obj(params, func) + return_value = func(**processed_params) if return_value is not None: - click.secho(return_value) + jsonified = json.dumps(return_value) + print(jsonified) except Exception as e: click.secho(f"Exception: {e}", fg="yellow") +def process_obj(some_obj, func) -> dict: + """ + Process a JSON-ish python obj into a param for the func + + Args: + some_obj (JSON-ish): A python dict, list, str, int, float, or bool + func (callable): A function object whose parameters are used for dictionary keys. + + Returns: + dict: Params for the func + """ + param_names = list(inspect.signature(func).parameters.keys()) + parameters = inspect.signature(func).parameters + + if isinstance(some_obj, dict): + return some_obj + elif isinstance(some_obj, list): + if len(param_names) < len(some_obj): + raise ValueError("Function parameters are fewer than the list items.") + # If the function expects a single list parameter, use it directly + if len(param_names) == 1: + param_type = parameters[param_names[0]].annotation + if get_origin(param_type) is list: + return {param_names[0]: some_obj} + # Otherwise, treat the list as a list of individual parameters + return {key: value for key, value in zip(param_names, some_obj)} + elif isinstance(some_obj, (str, int, float, bool)) or some_obj is None: + if not param_names: + raise ValueError("Function has no parameters to use as a key.") + return {param_names[0]: some_obj} + else: + raise TypeError("Unsupported type.") + + def cast_to_hint(value: str, hint: Any) -> Any: """ Cast a string value to the provided type hint. @@ -471,3 +525,13 @@ 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 format_func_with_docstring(func: Callable[..., Any]) -> str: + name = func.__name__ + if func.__doc__: + doc = func.__doc__.replace("\n", " ") + doc = doc[:96] + return f"{name:<25} ({doc})" + else: + return name From 7e6eea3fcdac9fbd076a94ad8ef3e0c9f0147365 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 01:38:22 -0600 Subject: [PATCH 52/72] get mission=simln --- test/simln_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/simln_test.py b/test/simln_test.py index 622174e9c..36829c151 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -45,7 +45,7 @@ def run_plugin(self): def copy_results(self) -> bool: self.log.info("Copying results") - pod = get_pods_with_label("mission=plugin")[0] + pod = get_pods_with_label("mission=simln")[0] self.wait_for_gossip_sync(2) wait_for_pod(pod.metadata.name, 60) sleep(20) From f0f107df2ff8bf8c3549fa8b85bf6fefdc93745a Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 02:08:58 -0600 Subject: [PATCH 53/72] fix call to download --- test/simln_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/simln_test.py b/test/simln_test.py index 36829c151..18bd9c176 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -55,7 +55,7 @@ def copy_results(self) -> bool: self.log.info("Sleep to process results") sleep(60) - download(pod.metadata.name, pod.metadata.namespace, Path("/working/results"), Path(".")) + download(pod.metadata.name, Path("/working/results"), Path("."), pod.metadata.namespace) for root, _dirs, files in os.walk(Path("results")): for file_name in files: From cf84808c9b3bfb654cd9302ad75760880555c925 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 02:32:20 -0600 Subject: [PATCH 54/72] wait longer for everyone to have a host --- resources/plugins/simln/simln.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 402b571c9..12ecbcaff 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -115,7 +115,7 @@ def everyone_has_a_host() -> bool: def wait_for_everyone_to_have_a_host(): - _wait_for_predicate(everyone_has_a_host) + _wait_for_predicate(everyone_has_a_host, timeout=10 * 60) def _wait_for_predicate(predicate, timeout=5 * 60, interval=5): From a39955a043a0f30c076ed02ba83c3cb0ca078efb Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 15:33:38 -0600 Subject: [PATCH 55/72] improve function menu --- src/warnet/plugin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 3821bef8e..389ad19c1 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -155,7 +155,7 @@ def run(plugin_name: str, function_name: str, json_dict: str): if not function_answer: sys.exit(0) function_name_with_doc = function_answer.get("func") - function_name = function_name_with_doc.split("(")[0].strip() + function_name = function_name_with_doc.split("\t")[0].strip() func = get_func(function_name=function_name, plugin_name=plugin_name) hints = get_type_hints(func) @@ -532,6 +532,7 @@ def format_func_with_docstring(func: Callable[..., Any]) -> str: if func.__doc__: doc = func.__doc__.replace("\n", " ") doc = doc[:96] - return f"{name:<25} ({doc})" + doc = click.style(doc, italic=True) + return f"{name:<25}\t{doc}" else: return name From cef2766c9b356c16c2df77a621e759cf75b7ecf4 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 19:06:57 -0600 Subject: [PATCH 56/72] add error handling around get_example_activity --- resources/plugins/simln/simln.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 12ecbcaff..73d002217 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -21,6 +21,10 @@ lightning_selector = "mission=lightning" +class SimLNError(Exception): + pass + + def run_simln(): """Run a SimLN Plugin demo""" init_network() @@ -48,8 +52,13 @@ def _prepare_and_launch_activity() -> str: def get_example_activity() -> list[dict]: """Get an activity representing node 2 sending msat to node 3""" pods = get_pods_with_label(lightning_selector) - pod_a = pods[1].metadata.name - pod_b = pods[2].metadata.name + 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}] From a2abbdbd489ab67342453c84de1427f1bbccc6e6 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 19:07:32 -0600 Subject: [PATCH 57/72] add positional params --- src/warnet/plugin.py | 58 ++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 389ad19c1..9653850d4 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -23,6 +23,10 @@ WARNET_USER_DIR_ENV_VAR, ) +# TODO Add inquirer test +# TODO get rid of piping +# TODO iron out input (and test it) + class PluginError(Exception): pass @@ -99,35 +103,21 @@ def toggle(plugin: str): @plugin.command() @click.argument("plugin_name", type=str, default="") @click.argument("function_name", type=str, default="") -@click.option("--json-dict", default="", type=str, help="Use json dict to populate parameters") -def run(plugin_name: str, function_name: str, json_dict: str): +@click.option("--params", type=str, default="") +@click.option("--json-input", type=str, default="") +def run(plugin_name: str, function_name: str, params: str, json_input: str): """Explore and run plugins""" - show_explainer = False plugin_dir = _get_plugin_directory() if plugin_dir is None: direct_user_to_plugin_directory_and_exit() - if not json_dict and not sys.stdin.isatty(): - # read from a pipe: $ echo "something" | warnet plugin run - json_dict = sys.stdin.read() - if not plugin_name or not function_name: - click.secho( - "You must specify a plugin name and function name when piping in data.", fg="yellow" - ) - click.secho("Alternative: warnet plugin run --json-dict YOUR_DATA_HERE") - sys.exit(1) - - if not plugin_dir: - click.secho("\nConsider setting environment variable containing your project directory:") - sys.exit(0) - plugins = get_plugins_with_status(plugin_dir) for plugin_path, status in plugins: if plugin_path.stem == plugin_name and not status: click.secho(f"The plugin '{plugin_path.stem}' is not enabled", fg="yellow") - click.secho("Please toggle it on to run commands.") + click.secho("Please toggle it on to use it.") sys.exit(0) if plugin_name == "" and sys.stdin.isatty(): @@ -162,7 +152,20 @@ def run(plugin_name: str, function_name: str, json_dict: str): if not func: sys.exit(0) - if not json_dict: + if params: + print(params) + params = json.loads(params) + try: + return_value = func(*params) + if return_value is not None: + jsonified = json.dumps(return_value) + print(f"'{jsonified}'") + sys.exit(0) + except Exception as e: + click.secho(f"Exception: {e}", fg="yellow") + sys.exit(1) + + if not json_input and not params: params = {} sig = inspect.signature(func) for name, param in sig.parameters.items(): @@ -201,24 +204,21 @@ def run(plugin_name: str, function_name: str, json_dict: str): ) else: click.secho( - f"\nwarnet plugin run {plugin_name} {function_name} --json-dict '{json.dumps(params)}'", - fg="green", - ) - click.secho( - f"echo '{json.dumps(params)}' | warnet plugin run {plugin_name} {function_name}\n", + f"\nwarnet plugin run {plugin_name} {function_name} --json-input '{json.dumps(params)}'", fg="green", ) + else: - params = json.loads(json_dict) + params = json.loads(json_input) try: - processed_params = process_obj(params, func) - return_value = func(**processed_params) + return_value = func(**params) if return_value is not None: jsonified = json.dumps(return_value) - print(jsonified) + print(f"'{jsonified}'") except Exception as e: click.secho(f"Exception: {e}", fg="yellow") + sys.exit(1) def process_obj(some_obj, func) -> dict: @@ -238,7 +238,7 @@ def process_obj(some_obj, func) -> dict: if isinstance(some_obj, dict): return some_obj elif isinstance(some_obj, list): - if len(param_names) < len(some_obj): + if len(param_names) < len(some_obj): # TODO: Move this b/c it shortcuts raise ValueError("Function parameters are fewer than the list items.") # If the function expects a single list parameter, use it directly if len(param_names) == 1: From 1d4227fb591a03ae6d48abfd1af31fdbd283f70d Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 20:30:22 -0600 Subject: [PATCH 58/72] trim the fat on parameter parsing --- src/warnet/plugin.py | 93 ++++++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 30 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 9653850d4..4b716e4b5 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -5,6 +5,8 @@ import os import sys import tempfile +from dataclasses import dataclass +from enum import Enum, auto from importlib.metadata import PackageNotFoundError, version from pathlib import Path from types import ModuleType @@ -32,6 +34,17 @@ class PluginError(Exception): pass +class ParamStrategy(Enum): + POSITIONAL = auto() + NAMED = auto() + + +@dataclass +class Params: + params: list | dict + type: ParamStrategy + + hook_registry: set[Callable[..., Any]] = set() imported_modules: dict[str, ModuleType] = {} @@ -103,10 +116,20 @@ def toggle(plugin: str): @plugin.command() @click.argument("plugin_name", type=str, default="") @click.argument("function_name", type=str, default="") -@click.option("--params", type=str, default="") -@click.option("--json-input", type=str, default="") -def run(plugin_name: str, function_name: str, params: str, json_input: str): - """Explore and run plugins""" +@click.option( + "--params", type=str, default="", help="Paramter data to be fed to the plugin function" +) +def run(plugin_name: str, function_name: str, params: str): + """Explore and run plugins + + Use `--params` to pass a JSON list for positional arguments or a JSON object for named arguments. + + Like this: + + Positional - '["first element", 2, 3.0]' + + Named - '{"first": "first_element", "second": 2, "third": 3.0}' + """ show_explainer = False plugin_dir = _get_plugin_directory() @@ -114,13 +137,19 @@ def run(plugin_name: str, function_name: str, params: str, json_input: str): direct_user_to_plugin_directory_and_exit() plugins = get_plugins_with_status(plugin_dir) + plugin_was_found = False for plugin_path, status in plugins: + if plugin_path.stem == plugin_name: + plugin_was_found = True if plugin_path.stem == plugin_name and not status: click.secho(f"The plugin '{plugin_path.stem}' is not enabled", fg="yellow") click.secho("Please toggle it on to use it.") sys.exit(0) + if plugin_name and not plugin_was_found: + click.secho(f"The plugin '{plugin_name}' was not found.", fg="yellow") + sys.exit(0) - if plugin_name == "" and sys.stdin.isatty(): + if plugin_name == "": show_explainer = True plugin_names = [ plugin_name.stem for plugin_name, status in get_plugins_with_status() if status @@ -132,7 +161,7 @@ def run(plugin_name: str, function_name: str, params: str, json_input: str): sys.exit(0) plugin_name = plugin_answer.get("plugin") - if function_name == "" and sys.stdin.isatty(): + if function_name == "": show_explainer = True module = imported_modules.get(f"plugins.{plugin_name}") funcs = [ @@ -152,20 +181,7 @@ def run(plugin_name: str, function_name: str, params: str, json_input: str): if not func: sys.exit(0) - if params: - print(params) - params = json.loads(params) - try: - return_value = func(*params) - if return_value is not None: - jsonified = json.dumps(return_value) - print(f"'{jsonified}'") - sys.exit(0) - except Exception as e: - click.secho(f"Exception: {e}", fg="yellow") - sys.exit(1) - - if not json_input and not params: + if not params: params = {} sig = inspect.signature(func) for name, param in sig.parameters.items(): @@ -207,18 +223,35 @@ def run(plugin_name: str, function_name: str, params: str, json_input: str): f"\nwarnet plugin run {plugin_name} {function_name} --json-input '{json.dumps(params)}'", fg="green", ) - else: - params = json.loads(json_input) + params = json.loads(params) - try: - return_value = func(**params) - if return_value is not None: - jsonified = json.dumps(return_value) - print(f"'{jsonified}'") - except Exception as e: - click.secho(f"Exception: {e}", fg="yellow") - sys.exit(1) + execute_function_with_params(func, params) + + +def execute_function_with_params(func: Callable[..., Any], params: dict | list): + match params: + case dict(): + try: + return_value = func(**params) + if return_value is not None: + jsonified = json.dumps(return_value) + print(f"'{jsonified}'") + except Exception as e: + click.secho(f"Exception: {e}", fg="yellow") + sys.exit(1) + case list(): + try: + return_value = func(*params) + if return_value is not None: + jsonified = json.dumps(return_value) + print(f"'{jsonified}'") + except Exception as e: + click.secho(f"Exception: {e}", fg="yellow") + sys.exit(1) + case _: + click.secho(f"Did not anticipate this type: {params} --> {type(params)}") + sys.exit(1) def process_obj(some_obj, func) -> dict: From f6871edf17c1708897712a2526971421451c2e80 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 20:31:54 -0600 Subject: [PATCH 59/72] clear out unneeded Param classes --- src/warnet/plugin.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 4b716e4b5..caa7ee0df 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -5,8 +5,6 @@ import os import sys import tempfile -from dataclasses import dataclass -from enum import Enum, auto from importlib.metadata import PackageNotFoundError, version from pathlib import Path from types import ModuleType @@ -26,7 +24,6 @@ ) # TODO Add inquirer test -# TODO get rid of piping # TODO iron out input (and test it) @@ -34,17 +31,6 @@ class PluginError(Exception): pass -class ParamStrategy(Enum): - POSITIONAL = auto() - NAMED = auto() - - -@dataclass -class Params: - params: list | dict - type: ParamStrategy - - hook_registry: set[Callable[..., Any]] = set() imported_modules: dict[str, ModuleType] = {} From b4f846891b49c381fc2bcb974c7bd3d7d4f8c916 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 21:34:57 -0600 Subject: [PATCH 60/72] minor plugin cleanups --- src/warnet/plugin.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index caa7ee0df..827770fcb 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -103,7 +103,7 @@ def toggle(plugin: str): @click.argument("plugin_name", type=str, default="") @click.argument("function_name", type=str, default="") @click.option( - "--params", type=str, default="", help="Paramter data to be fed to the plugin function" + "--params", type=str, default="", help="Parameter data to be fed to the plugin function" ) def run(plugin_name: str, function_name: str, params: str): """Explore and run plugins @@ -206,7 +206,7 @@ def run(plugin_name: str, function_name: str, params: str): ) else: click.secho( - f"\nwarnet plugin run {plugin_name} {function_name} --json-input '{json.dumps(params)}'", + f"\nwarnet plugin run {plugin_name} {function_name} --params '{json.dumps(params)}'", fg="green", ) else: @@ -221,8 +221,7 @@ def execute_function_with_params(func: Callable[..., Any], params: dict | list): try: return_value = func(**params) if return_value is not None: - jsonified = json.dumps(return_value) - print(f"'{jsonified}'") + print(json.dumps(return_value)) except Exception as e: click.secho(f"Exception: {e}", fg="yellow") sys.exit(1) @@ -230,8 +229,7 @@ def execute_function_with_params(func: Callable[..., Any], params: dict | list): try: return_value = func(*params) if return_value is not None: - jsonified = json.dumps(return_value) - print(f"'{jsonified}'") + print(json.dumps(return_value)) except Exception as e: click.secho(f"Exception: {e}", fg="yellow") sys.exit(1) From 5d4104cd6531f9a5753b8c2850c48e736a67fa2a Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 21 Nov 2024 21:35:23 -0600 Subject: [PATCH 61/72] expand simln test --- test/simln_test.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/test/simln_test.py b/test/simln_test.py index 18bd9c176..b50f28f47 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -2,7 +2,6 @@ import json import os from pathlib import Path -from subprocess import run from time import sleep import pexpect @@ -13,8 +12,12 @@ lightning_selector = "mission=lightning" +UP = "\033[A" +DOWN = "\033[B" +ENTER = "\n" -class LNTest(TestBase): + +class SimLNTest(TestBase): def __init__(self): super().__init__() self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" @@ -40,7 +43,36 @@ def run_plugin(self): self.sut.sendline("n") self.sut.close() - self.log.info(run(["warnet", "plugin", "run", "simln", "run_simln"])) + cmd = "warnet plugin run" + self.log.info(cmd) + self.sut = pexpect.spawn(cmd) + self.sut.expect("simln", timeout=10) + self.sut.send(ENTER) + self.sut.expect("run_simln", timeout=10) + self.sut.send(DOWN) + self.sut.send(DOWN) + self.sut.send(DOWN) + self.sut.send(DOWN) + self.sut.send(DOWN) + self.sut.send(DOWN) + self.sut.send(DOWN) + self.sut.send(DOWN) # run_simln + self.sut.send(ENTER) + self.sut.expect("Sent command", timeout=60 * 3) + self.sut.close() + + cmd = "warnet plugin run simln get_example_activity" + self.log.info(cmd) + self.sut = pexpect.spawn(cmd) + self.sut.expect("amount_msat", timeout=10) + self.sut.close() + + cmd = 'warnet plugin run simln launch_activity --params "$(warnet plugin run simln get_example_activity)"' + self.log.info(f"/bin/bash -c '{cmd}'") + self.sut = pexpect.spawn(f"/bin/bash -c '{cmd}'") + self.sut.expect("install simln", timeout=10) + self.sut.close() + sleep(10) def copy_results(self) -> bool: @@ -83,5 +115,5 @@ def wait_for_gossip_sync(self, expected: int): if __name__ == "__main__": - test = LNTest() + test = SimLNTest() test.run_test() From 97cad87bc1412e61bf63a02fe6cd6d8b5a16fe06 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 22 Nov 2024 19:47:39 -0600 Subject: [PATCH 62/72] misc cleanup --- resources/plugins/ark/plugin.yaml | 1 + resources/plugins/demo_plugin/demo.py | 6 ------ resources/plugins/demo_plugin/plugin.yaml | 1 - src/warnet/plugin.py | 2 +- 4 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 resources/plugins/demo_plugin/demo.py delete mode 100644 resources/plugins/demo_plugin/plugin.yaml diff --git a/resources/plugins/ark/plugin.yaml b/resources/plugins/ark/plugin.yaml index bc114417c..844e6b7ac 100644 --- a/resources/plugins/ark/plugin.yaml +++ b/resources/plugins/ark/plugin.yaml @@ -1 +1,2 @@ enabled: false + diff --git a/resources/plugins/demo_plugin/demo.py b/resources/plugins/demo_plugin/demo.py deleted file mode 100644 index 889da98eb..000000000 --- a/resources/plugins/demo_plugin/demo.py +++ /dev/null @@ -1,6 +0,0 @@ -from hooks_api import pre_status - - -@pre_status -def print_something_first(): - print("The demo plug is enabled.") diff --git a/resources/plugins/demo_plugin/plugin.yaml b/resources/plugins/demo_plugin/plugin.yaml deleted file mode 100644 index bc114417c..000000000 --- a/resources/plugins/demo_plugin/plugin.yaml +++ /dev/null @@ -1 +0,0 @@ -enabled: false diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 827770fcb..742ed3ae0 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -255,7 +255,7 @@ def process_obj(some_obj, func) -> dict: if isinstance(some_obj, dict): return some_obj elif isinstance(some_obj, list): - if len(param_names) < len(some_obj): # TODO: Move this b/c it shortcuts + if len(param_names) < len(some_obj): raise ValueError("Function parameters are fewer than the list items.") # If the function expects a single list parameter, use it directly if len(param_names) == 1: From afef50c624dca5533f6b5d4350273257050dcd9a Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 23 Nov 2024 18:52:55 -0600 Subject: [PATCH 63/72] update ark's dockerfiles & instructions --- resources/plugins/ark/ark.py | 36 +++++++++++-------- resources/plugins/ark/charts/aspd/values.yaml | 2 +- resources/plugins/ark/charts/bark/values.yaml | 2 +- .../plugins/ark/dockerfiles/aspd/Dockerfile | 9 +++++ .../plugins/ark/dockerfiles/bark/Dockerfile | 9 +++++ resources/plugins/ark/plugin.yaml | 2 +- 6 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 resources/plugins/ark/dockerfiles/aspd/Dockerfile create mode 100644 resources/plugins/ark/dockerfiles/bark/Dockerfile diff --git a/resources/plugins/ark/ark.py b/resources/plugins/ark/ark.py index d99292621..713d61fa7 100644 --- a/resources/plugins/ark/ark.py +++ b/resources/plugins/ark/ark.py @@ -1,15 +1,21 @@ -from hooks_api import post_status, pre_status - - -@pre_status -def print_something_first(): - print("The ark plugin is enabled.") - - -@post_status -def print_something_afterwards(): - print("The ark plugin executes after `status` has run.") - - -def run(): - print("Running the ark plugin") +def show_build_instructions(): + print("bark is an ark implementation that lives here: https://codeberg.org/ark-bitcoin/bark") + print("warnet init") + print("cd plugins/ark") + print("git clone git@codeberg.org:mpls/bark.git") + print("cd bark") + print("cargo build --workspace --release") + print("barkhead=$(git rev-parse --short HEAD)") + print("cd ../") + print("cp bark/target/release/bark dockerfiles/bark/") + print("cp bark/target/release/aspd dockerfiles/aspd/") + print("cd dockerfiles/bark") + print("docker build -t mplsgrant/bark:$barkhead .") + print("docker login") + print("docker push mplsgrant/bark:barkhead") + print("cd ../aspd") + print("docker build -t mplsgrant/aspd:$barkhead .") + print("docker push mplsgrant/aspd:$barkhead") + print( + "Don' forget to update the 'tag' section of the values.yaml file for aspd and bark with the value in $barkhead" + ) diff --git a/resources/plugins/ark/charts/aspd/values.yaml b/resources/plugins/ark/charts/aspd/values.yaml index d842fabf1..0f9fa9dae 100644 --- a/resources/plugins/ark/charts/aspd/values.yaml +++ b/resources/plugins/ark/charts/aspd/values.yaml @@ -2,7 +2,7 @@ name: "aspd" image: repository: "mplsgrant/aspd" - tag: "d1200b9" + tag: "af39ec4" pullPolicy: IfNotPresent network: regtest diff --git a/resources/plugins/ark/charts/bark/values.yaml b/resources/plugins/ark/charts/bark/values.yaml index 5a3f79cf2..a5f5570fe 100644 --- a/resources/plugins/ark/charts/bark/values.yaml +++ b/resources/plugins/ark/charts/bark/values.yaml @@ -1,7 +1,7 @@ name: "bark" image: repository: "mplsgrant/bark" - tag: "d1200b9" + tag: "af39ec4" pullPolicy: IfNotPresent command: ["sh", "-c"] args: ["while true; do sleep 3600; done"] diff --git a/resources/plugins/ark/dockerfiles/aspd/Dockerfile b/resources/plugins/ark/dockerfiles/aspd/Dockerfile new file mode 100644 index 000000000..0e2486fe3 --- /dev/null +++ b/resources/plugins/ark/dockerfiles/aspd/Dockerfile @@ -0,0 +1,9 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/* + +COPY aspd /usr/local/bin/aspd + +RUN chmod +x /usr/local/bin/aspd + +ENTRYPOINT ["/usr/local/bin/aspd"] diff --git a/resources/plugins/ark/dockerfiles/bark/Dockerfile b/resources/plugins/ark/dockerfiles/bark/Dockerfile new file mode 100644 index 000000000..8160bc3f1 --- /dev/null +++ b/resources/plugins/ark/dockerfiles/bark/Dockerfile @@ -0,0 +1,9 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/* + +COPY bark /usr/local/bin/bark + +RUN chmod +x /usr/local/bin/bark + +ENTRYPOINT ["/usr/local/bin/bark"] diff --git a/resources/plugins/ark/plugin.yaml b/resources/plugins/ark/plugin.yaml index 844e6b7ac..1e9cfcd3a 100644 --- a/resources/plugins/ark/plugin.yaml +++ b/resources/plugins/ark/plugin.yaml @@ -1,2 +1,2 @@ -enabled: false +enabled: true From ec48e421f2e767357ca158209975dcec0ecb2660 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 25 Nov 2024 11:30:31 -0600 Subject: [PATCH 64/72] clean up imports, ark --- resources/plugins/ark/plugin.yaml | 2 +- src/warnet/control.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/resources/plugins/ark/plugin.yaml b/resources/plugins/ark/plugin.yaml index 1e9cfcd3a..844e6b7ac 100644 --- a/resources/plugins/ark/plugin.yaml +++ b/resources/plugins/ark/plugin.yaml @@ -1,2 +1,2 @@ -enabled: true +enabled: false diff --git a/src/warnet/control.py b/src/warnet/control.py index 1df97b953..1105aabb3 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -41,7 +41,6 @@ wait_for_pod, write_file_to_container, ) -from .plugin import api from .process import run_command, stream_command console = Console() @@ -49,7 +48,6 @@ @click.command() @click.argument("scenario_name", required=False) -@api def stop(scenario_name): """Stop a running scenario or all scenarios""" active_scenarios = [sc.metadata.name for sc in get_mission("commander")] @@ -129,7 +127,6 @@ def stop_all_scenarios(scenarios): help="Skip confirmations", ) @click.command() -@api def down(force): """Bring down a running warnet quickly""" @@ -236,7 +233,6 @@ def get_active_network(namespace): ) @click.argument("additional_args", nargs=-1, type=click.UNPROCESSED) @click.option("--namespace", default=None, show_default=True) -@api def run( scenario_file: str, debug: bool, From 02a0b99219b84507b3056126eac984f2c525509c Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 09:56:31 -0600 Subject: [PATCH 65/72] add simpler plugins with click --- resources/plugins/simln/simln.py | 19 +++++++++++ src/warnet/deploy.py | 2 -- src/warnet/main.py | 8 ++++- src/warnet/plugin.py | 57 ++++++++++++++++++++------------ 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 73d002217..5e3f06dfa 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -5,6 +5,8 @@ from subprocess import run from time import sleep +import click + from warnet.k8s import download, get_pods_with_label, wait_for_pod from warnet.plugin import _get_plugin_directory as get_plugin_directory from warnet.process import run_command @@ -258,3 +260,20 @@ def list_simln_podnames() -> list[str]: def download_results(pod_name: str): """Download SimLN results to the current directory""" download(pod_name, source_path=Path("/working/results")) + + +@click.group() +def pname(): + """Commands for PluginName.""" + pass + + +@pname.command() +def pthing(): + """Do another thing.""" + click.echo("Plugin is doing another thing!") + run_simln() + + +def _register(register_command): + register_command(pname) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index f053d2967..59d8d057f 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -30,7 +30,6 @@ wait_for_ingress_controller, wait_for_pod_ready, ) -from .plugin import api from .process import stream_command HINT = "\nAre you trying to run a scenario? See `warnet run --help`" @@ -57,7 +56,6 @@ def validate_directory(ctx, param, value): @click.option("--namespace", type=str, help="Specify a namespace in which to deploy the network") @click.option("--to-all-users", is_flag=True, help="Deploy network to all user namespaces") @click.argument("unknown_args", nargs=-1) -@api def deploy(directory, debug, namespace, to_all_users, unknown_args): """Deploy a warnet with topology loaded from """ if unknown_args: diff --git a/src/warnet/main.py b/src/warnet/main.py index a064f6ade..ebaeb8031 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -8,7 +8,7 @@ from .graph import create, graph, import_network from .image import image from .ln import ln -from .plugin import plugin +from .plugin import load_plugins, plugin, register from .project import init, new, setup from .status import status from .users import auth @@ -39,6 +39,12 @@ def cli(): cli.add_command(stop) cli.add_command(create) cli.add_command(plugin) +cli.add_command(register) + + +@load_plugins +def load_early(): + pass if __name__ == "__main__": diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 742ed3ae0..8eb9012ae 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -23,8 +23,11 @@ WARNET_USER_DIR_ENV_VAR, ) -# TODO Add inquirer test -# TODO iron out input (and test it) + +@click.group(name="register") +def register(): + """Registered plugins""" + pass class PluginError(Exception): @@ -216,27 +219,21 @@ def run(plugin_name: str, function_name: str, params: str): def execute_function_with_params(func: Callable[..., Any], params: dict | list): - match params: - case dict(): - try: - return_value = func(**params) - if return_value is not None: - print(json.dumps(return_value)) - except Exception as e: - click.secho(f"Exception: {e}", fg="yellow") - sys.exit(1) - case list(): - try: - return_value = func(*params) - if return_value is not None: - print(json.dumps(return_value)) - except Exception as e: - click.secho(f"Exception: {e}", fg="yellow") - sys.exit(1) - case _: - click.secho(f"Did not anticipate this type: {params} --> {type(params)}") + try: + if isinstance(params, dict): + return_value = func(**params) + elif isinstance(params, list): + return_value = func(*params) + else: + click.secho(f"Did not anticipate this type: {params} --> {type(params)}", fg="red") sys.exit(1) + if return_value is not None: + print(json.dumps(return_value)) + except Exception as e: + click.secho(f"Exception: {e}", fg="yellow") + sys.exit(1) + def process_obj(some_obj, func) -> dict: """ @@ -452,6 +449,22 @@ def load_user_modules() -> bool: return was_successful_load +def register_command(command): + """Register a command to the CLI.""" + from warnet.main import cli + + register = cli.commands.get("register") + 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 == "_register": + func(register_command) + + def find_hooks(module_name: str, func_name: str): module = imported_modules.get(module_name) pre_hooks = [] @@ -550,6 +563,6 @@ def format_func_with_docstring(func: Callable[..., Any]) -> str: doc = func.__doc__.replace("\n", " ") doc = doc[:96] doc = click.style(doc, italic=True) - return f"{name:<25}\t{doc}" + return f"{name:<35}\t{doc}" else: return name From 4d8397fb5fd12803a89b9c44e39bbf2af51b9fe9 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 10:25:59 -0600 Subject: [PATCH 66/72] remove cruft --- src/warnet/plugin.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 8eb9012ae..7952ab1f3 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -24,12 +24,6 @@ ) -@click.group(name="register") -def register(): - """Registered plugins""" - pass - - class PluginError(Exception): pass @@ -44,15 +38,6 @@ def plugin(): pass -@click.group(name="util") -def util(): - """Plugin utility functions""" - pass - - -plugin.add_command(util) - - @plugin.command() def ls(): """List all available plugins and whether they are activated""" @@ -500,11 +485,6 @@ def direct_user_to_plugin_directory_and_exit(): sys.exit(1) -@util.command() -def get_plugin_directory(): - click.secho(_get_plugin_directory()) - - def get_version(package_name: str) -> str: try: return version(package_name) From 141c7382ab94d1d6fe55489f827e83db2d99b132 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 10:33:20 -0600 Subject: [PATCH 67/72] remove `run` from plugins --- src/warnet/plugin.py | 229 +------------------------------------------ 1 file changed, 1 insertion(+), 228 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 7952ab1f3..69dbdf8fd 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -1,14 +1,13 @@ import copy import importlib.util import inspect -import json import os import sys import tempfile from importlib.metadata import PackageNotFoundError, version from pathlib import Path from types import ModuleType -from typing import Any, Callable, Optional, Union, get_args, get_origin, get_type_hints +from typing import Any, Callable, Optional import click import inquirer @@ -87,221 +86,6 @@ def toggle(plugin: str): write_yaml(updated_settings, plugin_dir / Path(plugin) / Path("plugin.yaml")) -@plugin.command() -@click.argument("plugin_name", type=str, default="") -@click.argument("function_name", type=str, default="") -@click.option( - "--params", type=str, default="", help="Parameter data to be fed to the plugin function" -) -def run(plugin_name: str, function_name: str, params: str): - """Explore and run plugins - - Use `--params` to pass a JSON list for positional arguments or a JSON object for named arguments. - - Like this: - - Positional - '["first element", 2, 3.0]' - - Named - '{"first": "first_element", "second": 2, "third": 3.0}' - """ - show_explainer = False - - plugin_dir = _get_plugin_directory() - if plugin_dir is None: - direct_user_to_plugin_directory_and_exit() - - plugins = get_plugins_with_status(plugin_dir) - plugin_was_found = False - for plugin_path, status in plugins: - if plugin_path.stem == plugin_name: - plugin_was_found = True - if plugin_path.stem == plugin_name and not status: - click.secho(f"The plugin '{plugin_path.stem}' is not enabled", fg="yellow") - click.secho("Please toggle it on to use it.") - sys.exit(0) - if plugin_name and not plugin_was_found: - click.secho(f"The plugin '{plugin_name}' was not found.", fg="yellow") - sys.exit(0) - - if plugin_name == "": - show_explainer = True - plugin_names = [ - plugin_name.stem for plugin_name, status in get_plugins_with_status() if status - ] - q = [inquirer.List(name="plugin", message="Please choose a plugin", choices=plugin_names)] - - plugin_answer = inquirer.prompt(q, theme=GreenPassion()) - if not plugin_answer: - sys.exit(0) - plugin_name = plugin_answer.get("plugin") - - if function_name == "": - show_explainer = True - module = imported_modules.get(f"plugins.{plugin_name}") - funcs = [ - format_func_with_docstring(func) - for _name, func in inspect.getmembers(module, inspect.isfunction) - if func.__module__ == "plugins." + plugin_name and not func.__name__.startswith("_") - ] - q = [inquirer.List(name="func", message="Please choose a function", choices=funcs)] - function_answer = inquirer.prompt(q, theme=GreenPassion()) - if not function_answer: - sys.exit(0) - function_name_with_doc = function_answer.get("func") - function_name = function_name_with_doc.split("\t")[0].strip() - - func = get_func(function_name=function_name, plugin_name=plugin_name) - hints = get_type_hints(func) - if not func: - sys.exit(0) - - if not params: - params = {} - sig = inspect.signature(func) - for name, param in sig.parameters.items(): - hint = hints.get(name) - hint_name = get_type_name(hint) - if param.default != inspect.Parameter.empty: - q = [ - inquirer.Text( - "input", - message=f"Enter a value for '{name}' ({hint_name})", - default=param.default, - ) - ] - else: - q = [ - inquirer.Text( - "input", - message=f"Enter a value for '{name}' ({hint_name})", - ) - ] - user_input_answer = inquirer.prompt(q) - if not user_input_answer: - sys.exit(0) - user_input = user_input_answer.get("input") - - if hint is None: - params[name] = user_input - else: - params[name] = cast_to_hint(user_input, hint) - - if show_explainer: - if not params: - click.secho( - f"\nwarnet plugin run {plugin_name} {function_name}\n", - fg="green", - ) - else: - click.secho( - f"\nwarnet plugin run {plugin_name} {function_name} --params '{json.dumps(params)}'", - fg="green", - ) - else: - params = json.loads(params) - - execute_function_with_params(func, params) - - -def execute_function_with_params(func: Callable[..., Any], params: dict | list): - try: - if isinstance(params, dict): - return_value = func(**params) - elif isinstance(params, list): - return_value = func(*params) - else: - click.secho(f"Did not anticipate this type: {params} --> {type(params)}", fg="red") - sys.exit(1) - - if return_value is not None: - print(json.dumps(return_value)) - except Exception as e: - click.secho(f"Exception: {e}", fg="yellow") - sys.exit(1) - - -def process_obj(some_obj, func) -> dict: - """ - Process a JSON-ish python obj into a param for the func - - Args: - some_obj (JSON-ish): A python dict, list, str, int, float, or bool - func (callable): A function object whose parameters are used for dictionary keys. - - Returns: - dict: Params for the func - """ - param_names = list(inspect.signature(func).parameters.keys()) - parameters = inspect.signature(func).parameters - - if isinstance(some_obj, dict): - return some_obj - elif isinstance(some_obj, list): - if len(param_names) < len(some_obj): - raise ValueError("Function parameters are fewer than the list items.") - # If the function expects a single list parameter, use it directly - if len(param_names) == 1: - param_type = parameters[param_names[0]].annotation - if get_origin(param_type) is list: - return {param_names[0]: some_obj} - # Otherwise, treat the list as a list of individual parameters - return {key: value for key, value in zip(param_names, some_obj)} - elif isinstance(some_obj, (str, int, float, bool)) or some_obj is None: - if not param_names: - raise ValueError("Function has no parameters to use as a key.") - return {param_names[0]: some_obj} - else: - raise TypeError("Unsupported type.") - - -def cast_to_hint(value: str, hint: Any) -> Any: - """ - Cast a string value to the provided type hint. - """ - origin = get_origin(hint) - args = get_args(hint) - - # Handle basic types (int, str, float, etc.) - if origin is None: - return hint(value) - - # Handle Union (e.g., Union[int, str]) - if origin is Union: - for arg in args: - try: - return cast_to_hint(value, arg) - except (ValueError, TypeError): - continue - raise ValueError(f"Cannot cast {value} to {hint}") - - # Handle Lists (e.g., List[int]) - if origin is list: - return [cast_to_hint(v.strip(), args[0]) for v in value.split(",")] - - raise ValueError(f"Unsupported hint: {hint}") - - -def get_type_name(type_hint) -> str: - if type_hint is None: - return "Unknown type" - if hasattr(type_hint, "__name__"): - return type_hint.__name__ - return str(type_hint) - - -def get_func(function_name: str, plugin_name: str) -> Optional[Callable[..., Any]]: - module = imported_modules.get(f"plugins.{plugin_name}") - if hasattr(module, function_name): - func = getattr(module, function_name) - if callable(func): - return func - else: - click.secho(f"{function_name} in {module} is not callable.") - else: - click.secho(f"Could not find {function_name} in {module}") - return None - - def api(func: Callable[..., Any]) -> Callable[..., Any]: """ Functions with this decoration will have corresponding 'pre' and 'post' functions made @@ -535,14 +319,3 @@ 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 format_func_with_docstring(func: Callable[..., Any]) -> str: - name = func.__name__ - if func.__doc__: - doc = func.__doc__.replace("\n", " ") - doc = doc[:96] - doc = click.style(doc, italic=True) - return f"{name:<35}\t{doc}" - else: - return name From f9e527d24ac1501a5f8d49ef7cfa2f452a02080c Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 10:36:12 -0600 Subject: [PATCH 68/72] remove `api` --- src/warnet/network.py | 2 - src/warnet/plugin.py | 123 ------------------------------------------ 2 files changed, 125 deletions(-) diff --git a/src/warnet/network.py b/src/warnet/network.py index 4fa236efb..e6658ae8c 100644 --- a/src/warnet/network.py +++ b/src/warnet/network.py @@ -11,7 +11,6 @@ SCENARIOS_DIR, ) from .k8s import get_mission -from .plugin import create_hooks def copy_defaults(directory: Path, target_subdir: str, source_path: Path, exclude_list: list[str]): @@ -58,7 +57,6 @@ def copy_plugins_defaults(directory: Path): PLUGINS_DIR, ["__pycache__", "__init__"], ) - create_hooks(directory / PLUGINS_DIR.name) def is_connection_manual(peer): diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 69dbdf8fd..8617ed4d6 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -4,7 +4,6 @@ import os import sys import tempfile -from importlib.metadata import PackageNotFoundError, version from pathlib import Path from types import ModuleType from typing import Any, Callable, Optional @@ -15,8 +14,6 @@ from inquirer.themes import GreenPassion from warnet.constants import ( - HOOK_NAME_KEY, - HOOKS_API_FILE, HOOKS_API_STEM, PLUGINS_LABEL, WARNET_USER_DIR_ENV_VAR, @@ -86,97 +83,6 @@ def toggle(plugin: str): write_yaml(updated_settings, plugin_dir / Path(plugin) / Path("plugin.yaml")) -def api(func: Callable[..., Any]) -> Callable[..., Any]: - """ - Functions with this decoration will have corresponding 'pre' and 'post' functions made - available to the user via the 'plugins' directory. - - Please ensure that @api is the innermost decorator: - - ```python - @click.command() # outermost - @api # innermost - def my_function(): - pass - ``` - """ - if func.__name__ in [fn.__name__ for fn in hook_registry]: - print( - f"Cannot re-use function names in the Warnet plugin API -- " - f"'{func.__name__}' has already been taken." - ) - sys.exit(1) - hook_registry.add(func) - - if not imported_modules: - load_user_modules() - - pre_hooks, post_hooks = [], [] - for module_name in imported_modules: - pre, post = find_hooks(module_name, func.__name__) - pre_hooks.extend(pre) - post_hooks.extend(post) - - def wrapped(*args, **kwargs): - for hook in pre_hooks: - hook() - result = func(*args, **kwargs) - for hook in post_hooks: - hook() - return result - - # Mimic the base function; helps make `click` happy - wrapped.__name__ = func.__name__ - wrapped.__doc__ = func.__doc__ - - return wrapped - - -def create_hooks(directory: Path): - # Prepare directory and file - os.makedirs(directory, exist_ok=True) - init_file_path = os.path.join(directory, HOOKS_API_FILE) - - with open(init_file_path, "w") as file: - file.write(f"# API Version: {get_version('warnet')}") - # For each enum variant, create a corresponding decorator function - for func in hook_registry: - file.write( - decorator_code.format( - hook=func.__name__, doc=func.__doc__, HOOK_NAME_KEY=HOOK_NAME_KEY - ) - ) - - click.secho("\nConsider setting an environment variable containing your project directory:") - click.secho(f"export {WARNET_USER_DIR_ENV_VAR}={directory.parent}\n", fg="yellow") - - -decorator_code = """ - - -def pre_{hook}(func): - \"\"\" - Functions with this decoration run before `{hook}`. - - `{hook}` documentation: - {doc} - \"\"\" - func.__annotations__['{HOOK_NAME_KEY}'] = 'pre_{hook}' - return func - - -def post_{hook}(func): - \"\"\" - Functions with this decoration run after `{hook}`. - - `{hook}` documentation: - {doc} - \"\"\" - func.__annotations__['{HOOK_NAME_KEY}'] = 'post_{hook}' - return func -""" - - def load_user_modules() -> bool: was_successful_load = False @@ -193,15 +99,6 @@ def load_user_modules() -> bool: # Temporarily add the directory to sys.path for imports sys.path.insert(0, str(plugin_dir)) - hooks_path = plugin_dir / HOOKS_API_FILE - - if hooks_path.is_file(): - hooks_spec = importlib.util.spec_from_file_location(HOOKS_API_STEM, hooks_path) - hooks_module = importlib.util.module_from_spec(hooks_spec) - imported_modules[HOOKS_API_STEM] = hooks_module - sys.modules[HOOKS_API_STEM] = hooks_module - hooks_spec.loader.exec_module(hooks_module) - for plugin_path in enabled_plugins: for file in plugin_path.glob("*.py"): if file.stem not in ("__init__", HOOKS_API_STEM): @@ -234,18 +131,6 @@ def load_plugins(fn): func(register_command) -def find_hooks(module_name: str, func_name: str): - module = imported_modules.get(module_name) - pre_hooks = [] - post_hooks = [] - for _, func in inspect.getmembers(module, inspect.isfunction): - if func.__annotations__.get(HOOK_NAME_KEY) == f"pre_{func_name}": - pre_hooks.append(func) - elif func.__annotations__.get(HOOK_NAME_KEY) == f"post_{func_name}": - post_hooks.append(func) - return pre_hooks, post_hooks - - def _get_plugin_directory() -> Optional[Path]: user_dir = os.getenv(WARNET_USER_DIR_ENV_VAR) @@ -269,14 +154,6 @@ def direct_user_to_plugin_directory_and_exit(): sys.exit(1) -def get_version(package_name: str) -> str: - try: - return version(package_name) - except PackageNotFoundError: - print(f"Package not found: {package_name}") - sys.exit(1) - - def read_yaml(path: Path) -> dict: try: with open(path) as file: From 84198a851ca8a44dd4821435ce37032def83552d Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 10:47:31 -0600 Subject: [PATCH 69/72] rectify plugin/register names --- src/warnet/main.py | 3 +-- src/warnet/plugin.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/warnet/main.py b/src/warnet/main.py index ebaeb8031..a900132ce 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -8,7 +8,7 @@ from .graph import create, graph, import_network from .image import image from .ln import ln -from .plugin import load_plugins, plugin, register +from .plugin import load_plugins, plugin from .project import init, new, setup from .status import status from .users import auth @@ -39,7 +39,6 @@ def cli(): cli.add_command(stop) cli.add_command(create) cli.add_command(plugin) -cli.add_command(register) @load_plugins diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index 8617ed4d6..d8c02b74a 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -119,7 +119,7 @@ def register_command(command): """Register a command to the CLI.""" from warnet.main import cli - register = cli.commands.get("register") + register = cli.commands.get("plugin") register.add_command(command) From 9e5f37e6e319e02ab062fbbbf3045c501fc52e6e Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 10:54:39 -0600 Subject: [PATCH 70/72] use better looking separator --- src/warnet/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/warnet/plugin.py b/src/warnet/plugin.py index d8c02b74a..3cbac71ad 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugin.py @@ -59,7 +59,7 @@ def toggle(plugin: str): 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 + f"{str(name.stem):<25} ◦ enabled: {active}" for name, active in plugin_list ] plugins_tag = "plugins" @@ -72,7 +72,7 @@ def toggle(plugin: str): ) ] 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) From 9cfdbc785000f73814c6db645b0a6e1c0085a5e4 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 14:34:54 -0600 Subject: [PATCH 71/72] implement SimLN plugin via click --- resources/plugins/simln/simln.py | 177 +++++++++++++++++++-------- src/warnet/constants.py | 4 +- src/warnet/k8s.py | 4 +- src/warnet/main.py | 4 +- src/warnet/{plugin.py => plugins.py} | 30 ++--- test/simln_test.py | 47 +++---- 6 files changed, 163 insertions(+), 103 deletions(-) rename src/warnet/{plugin.py => plugins.py} (90%) diff --git a/resources/plugins/simln/simln.py b/resources/plugins/simln/simln.py index 5e3f06dfa..c57b76e38 100644 --- a/resources/plugins/simln/simln.py +++ b/resources/plugins/simln/simln.py @@ -6,9 +6,16 @@ from time import sleep import click - -from warnet.k8s import download, get_pods_with_label, wait_for_pod -from warnet.plugin import _get_plugin_directory as get_plugin_directory +from kubernetes.stream import stream + +from warnet.k8s import ( + download, + get_default_namespace, + get_pods_with_label, + 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 @@ -20,40 +27,62 @@ console_handler.setFormatter(formatter) log.addHandler(console_handler) -lightning_selector = "mission=lightning" +LIGHTNING_SELECTOR = "mission=lightning" + + +@click.group() +def simln(): + """Commands for the SimLN plugin""" + pass + + +def warnet_register_plugin(register_command): + register_command(simln) class SimLNError(Exception): pass -def run_simln(): - """Run a SimLN Plugin demo""" - init_network() - fund_wallets() - wait_for_everyone_to_have_a_host() +@simln.command() +def run_demo(): + """Run the SimLN Plugin demo""" + _init_network() + _fund_wallets() + _wait_for_everyone_to_have_a_host() log.info(warnet("bitcoin rpc tank-0000 -generate 7")) # warnet("ln open-all-channels") 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() + pod_name = prepare_and_launch_activity() log.info(pod_name) wait_for_pod(pod_name, 60) -def _prepare_and_launch_activity() -> str: - sample_activity = get_example_activity() +@simln.command() +def list_simln_podnames(): + """Get a list of simln pod names""" + print([pod.metadata.name for pod in get_pods_with_label("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) + pod_name = _launch_activity(sample_activity) log.info("Sent command. Done.") return pod_name -def get_example_activity() -> list[dict]: - """Get an activity representing node 2 sending msat to node 3""" - pods = get_pods_with_label(lightning_selector) +def _get_example_activity() -> list[dict]: + pods = get_pods_with_label(LIGHTNING_SELECTOR) try: pod_a = pods[1].metadata.name pod_b = pods[2].metadata.name @@ -64,7 +93,13 @@ def get_example_activity() -> list[dict]: return [{"source": pod_a, "destination": pod_b, "interval_secs": 1, "amount_msat": 2000}] -def launch_activity(activity: list[dict]) -> str: +@simln.command() +def get_example_activity(): + """Get an activity representing node 2 sending msat to node 3""" + print(_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() @@ -75,7 +110,15 @@ def launch_activity(activity: list[dict]) -> str: return f"simln-simln-{random_digits}" -def init_network(): +@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") @@ -85,7 +128,7 @@ def init_network(): _wait_for_predicate(lambda: int(warnet("bitcoin rpc tank-0000 getblockcount")) > 100) def wait_for_all_ln_rpc(): - lns = get_pods_with_label(lightning_selector) + lns = get_pods_with_label(LIGHTNING_SELECTOR) for v1_pod in lns: ln = v1_pod.metadata.name try: @@ -98,11 +141,16 @@ def wait_for_all_ln_rpc(): _wait_for_predicate(wait_for_all_ln_rpc) -def fund_wallets(): +@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_pods_with_label(lightning_selector) + lns = get_pods_with_label(LIGHTNING_SELECTOR) for v1_pod in lns: lnd = v1_pod.metadata.name addr = json.loads(warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"] @@ -113,9 +161,15 @@ def fund_wallets(): log.info(warnet("bitcoin rpc tank-0000 -generate 1")) -def everyone_has_a_host() -> bool: +@simln.command() +def fund_wallets(): + """Fund each ln node with 10 regtest coins.""" + _fund_wallets() + + +def _everyone_has_a_host() -> bool: """Find out if each ln node has a host.""" - pods = get_pods_with_label(lightning_selector) + pods = get_pods_with_label(LIGHTNING_SELECTOR) host_havers = 0 for pod in pods: name = pod.metadata.name @@ -125,8 +179,13 @@ def everyone_has_a_host() -> bool: return host_havers == len(pods) and host_havers != 0 +@simln.command() def wait_for_everyone_to_have_a_host(): - _wait_for_predicate(everyone_has_a_host, timeout=10 * 60) + log.info(_wait_for_everyone_to_have_a_host()) + + +def _wait_for_everyone_to_have_a_host(): + _wait_for_predicate(_everyone_has_a_host, timeout=10 * 60) def _wait_for_predicate(predicate, timeout=5 * 60, interval=5): @@ -173,7 +232,7 @@ def wait_for_gossip_sync(expected: int = 2): current = 0 while current < expected: current = 0 - pods = get_pods_with_label(lightning_selector) + 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"] @@ -196,7 +255,7 @@ def warnet(cmd: str = "--help"): def _generate_nodes_file(activity: list[dict], output_file: Path = Path("nodes.json")): nodes = [] - for i in get_pods_with_label(lightning_selector): + for i in get_pods_with_label(LIGHTNING_SELECTOR): name = i.metadata.name node = { "id": name, @@ -252,28 +311,44 @@ def wait_for_two_txs(): warnet("bitcoin rpc tank-0000 -generate 10") -def list_simln_podnames() -> list[str]: - """Get a list of simln pod names""" - return [pod.metadata.name for pod in get_pods_with_label("mission=simln")] - - -def download_results(pod_name: str): - """Download SimLN results to the current directory""" - download(pod_name, source_path=Path("/working/results")) - - -@click.group() -def pname(): - """Commands for PluginName.""" - pass - - -@pname.command() -def pthing(): - """Do another thing.""" - click.echo("Plugin is doing another thing!") - run_simln() - - -def _register(register_command): - register_command(pname) +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 1f35f2951..ee278cf49 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -40,10 +40,8 @@ # Plugin architecture PLUGINS_LABEL = "plugins" +PLUGIN_YAML = "plugin.yaml" PLUGINS_DIR = RESOURCES_DIR.joinpath(PLUGINS_LABEL) -HOOK_NAME_KEY = "hook_name" # this lives as a key in object.__annotations__ -HOOKS_API_STEM = "hooks_api" -HOOKS_API_FILE = HOOKS_API_STEM + ".py" WARNET_USER_DIR_ENV_VAR = "WARNET_USER_DIR" # Helm charts diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index 5737c79e2..8a1a65bce 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -569,7 +569,7 @@ def download( 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) @@ -607,3 +607,5 @@ def download( 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 a900132ce..64887c6ee 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -8,7 +8,7 @@ from .graph import create, graph, import_network from .image import image from .ln import ln -from .plugin import load_plugins, plugin +from .plugins import load_plugins, plugins from .project import init, new, setup from .status import status from .users import auth @@ -38,7 +38,7 @@ def cli(): cli.add_command(status) cli.add_command(stop) cli.add_command(create) -cli.add_command(plugin) +cli.add_command(plugins) @load_plugins diff --git a/src/warnet/plugin.py b/src/warnet/plugins.py similarity index 90% rename from src/warnet/plugin.py rename to src/warnet/plugins.py index 3cbac71ad..e91be5f16 100644 --- a/src/warnet/plugin.py +++ b/src/warnet/plugins.py @@ -14,7 +14,7 @@ from inquirer.themes import GreenPassion from warnet.constants import ( - HOOKS_API_STEM, + PLUGIN_YAML, PLUGINS_LABEL, WARNET_USER_DIR_ENV_VAR, ) @@ -28,16 +28,16 @@ class PluginError(Exception): imported_modules: dict[str, ModuleType] = {} -@click.group(name="plugin") -def plugin(): +@click.group(name=PLUGINS_LABEL) +def plugins(): """Control plugins""" pass -@plugin.command() +@plugins.command() def ls(): """List all available plugins and whether they are activated""" - plugin_dir = _get_plugin_directory() + plugin_dir = _get_plugins_directory() if plugin_dir is None: direct_user_to_plugin_directory_and_exit() @@ -48,11 +48,11 @@ def ls(): click.secho(f"{plugin.stem:<20} disabled", fg="yellow") -@plugin.command() +@plugins.command() @click.argument("plugin", type=str, default="") def toggle(plugin: str): """Toggle a plugin on or off""" - plugin_dir = _get_plugin_directory() + plugin_dir = _get_plugins_directory() if plugin_dir is None: direct_user_to_plugin_directory_and_exit() @@ -77,16 +77,16 @@ def toggle(plugin: str): # user cancels and `selected[plugins_tag] fails with TypeError sys.exit(0) - plugin_settings = read_yaml(plugin_dir / Path(plugin) / "plugin.yaml") + 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")) + write_yaml(updated_settings, plugin_dir / Path(plugin) / Path(PLUGIN_YAML)) def load_user_modules() -> bool: was_successful_load = False - plugin_dir = _get_plugin_directory() + plugin_dir = _get_plugins_directory() if not plugin_dir or not plugin_dir.is_dir(): return was_successful_load @@ -101,7 +101,7 @@ def load_user_modules() -> bool: for plugin_path in enabled_plugins: for file in plugin_path.glob("*.py"): - if file.stem not in ("__init__", HOOKS_API_STEM): + 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) @@ -119,7 +119,7 @@ def register_command(command): """Register a command to the CLI.""" from warnet.main import cli - register = cli.commands.get("plugin") + register = cli.commands.get(PLUGINS_LABEL) register.add_command(command) @@ -127,11 +127,11 @@ def load_plugins(fn): load_user_modules() for module in imported_modules.values(): for name, func in inspect.getmembers(module, inspect.isfunction): - if name == "_register": + if name == "warnet_register_plugin": func(register_command) -def _get_plugin_directory() -> Optional[Path]: +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 @@ -188,7 +188,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_plugin_directory() + plugin_dir = _get_plugins_directory() 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 b50f28f47..a4cbbe605 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import ast import json import os from pathlib import Path @@ -38,42 +39,15 @@ def setup_network(self): self.wait_for_all_tanks_status(target="running") def run_plugin(self): + self.log.info("Running SimLN plugin...") self.sut = pexpect.spawn("warnet init") self.sut.expect("network", timeout=10) self.sut.sendline("n") self.sut.close() - cmd = "warnet plugin run" - self.log.info(cmd) - self.sut = pexpect.spawn(cmd) - self.sut.expect("simln", timeout=10) - self.sut.send(ENTER) - self.sut.expect("run_simln", timeout=10) - self.sut.send(DOWN) - self.sut.send(DOWN) - self.sut.send(DOWN) - self.sut.send(DOWN) - self.sut.send(DOWN) - self.sut.send(DOWN) - self.sut.send(DOWN) - self.sut.send(DOWN) # run_simln - self.sut.send(ENTER) - self.sut.expect("Sent command", timeout=60 * 3) - self.sut.close() - - cmd = "warnet plugin run simln get_example_activity" - self.log.info(cmd) - self.sut = pexpect.spawn(cmd) - self.sut.expect("amount_msat", timeout=10) - self.sut.close() - - cmd = 'warnet plugin run simln launch_activity --params "$(warnet plugin run simln get_example_activity)"' - self.log.info(f"/bin/bash -c '{cmd}'") - self.sut = pexpect.spawn(f"/bin/bash -c '{cmd}'") - self.sut.expect("install simln", timeout=10) - self.sut.close() - - sleep(10) + run_command("warnet plugins simln run-simln") + self.wait_for_predicate(self.found_results) + print(run_command("warnet plugins simln get-example-activity")) def copy_results(self) -> bool: self.log.info("Copying results") @@ -96,7 +70,9 @@ def copy_results(self) -> bool: with open(file_path) as file: content = file.read() if "Success" in content: + self.log.info("Found downloaded results.") return True + self.log.info("Did not find downloaded results.") return False def wait_for_gossip_sync(self, expected: int): @@ -113,6 +89,15 @@ def wait_for_gossip_sync(self, expected: int): sleep(1) self.log.info("Synced") + def found_results(self) -> bool: + 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 = run_command(f"warnet plugins simln rpc {pod} ls /working/results") + self.log.info(f"Results file: {results}") + return len(results) > 10 + if __name__ == "__main__": test = SimLNTest() From 5f11d3f3794efc547e67bf0229cf33415379c226 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 26 Nov 2024 15:13:53 -0600 Subject: [PATCH 72/72] imporove test --- test/simln_test.py | 56 +++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/test/simln_test.py b/test/simln_test.py index a4cbbe605..1571ffa47 100755 --- a/test/simln_test.py +++ b/test/simln_test.py @@ -28,8 +28,7 @@ def run_test(self): os.chdir(self.tmpdir) self.setup_network() self.run_plugin() - result = self.copy_results() - assert result + self.copy_results() finally: self.cleanup() @@ -39,41 +38,29 @@ def setup_network(self): self.wait_for_all_tanks_status(target="running") def run_plugin(self): - self.log.info("Running SimLN plugin...") + 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() - run_command("warnet plugins simln run-simln") - self.wait_for_predicate(self.found_results) - print(run_command("warnet plugins simln get-example-activity")) + 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) -> bool: 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) - sleep(20) log_resp = pod_log(pod.metadata.name, "simln") self.log.info(log_resp.data.decode("utf-8")) - self.log.info("Sleep to process results") - sleep(60) download(pod.metadata.name, Path("/working/results"), Path("."), pod.metadata.namespace) - - for root, _dirs, files in os.walk(Path("results")): - 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("Found downloaded results.") - return True - self.log.info("Did not find downloaded results.") - return False + 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})...") @@ -89,14 +76,33 @@ def wait_for_gossip_sync(self, expected: int): sleep(1) self.log.info("Synced") - def found_results(self) -> bool: + def found_results_remotely(self) -> bool: 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 = run_command(f"warnet plugins simln rpc {pod} ls /working/results") - self.log.info(f"Results file: {results}") - return len(results) > 10 + 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__":