From 5112e030dc25960171c49b6037cd3d9da18f597a Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 23 Aug 2024 10:51:22 +0200 Subject: [PATCH 01/16] BREAKING REFACTOR: move some values to global, add lnd as subchart lnd nodes running, connected to tank, rpc working test: ensure ln channels and payments with rpc commands test: wait for ln nodes to be ready --- .github/workflows/test.yml | 1 + resources/charts/bitcoincore/Chart.yaml | 5 + .../charts/bitcoincore/charts/lnd/Chart.yaml | 25 ++++ .../charts/lnd/templates/_helpers.tpl | 78 +++++++++++ .../charts/lnd/templates/configmap.yaml | 19 +++ .../bitcoincore/charts/lnd/templates/pod.yaml | 74 +++++++++++ .../charts/lnd/templates/service.yaml | 20 +++ .../charts/bitcoincore/charts/lnd/values.yaml | 121 ++++++++++++++++++ .../charts/bitcoincore/templates/_helpers.tpl | 2 +- .../bitcoincore/templates/configmap.yaml | 10 +- .../charts/bitcoincore/templates/pod.yaml | 26 ++-- .../charts/bitcoincore/templates/service.yaml | 8 +- resources/charts/bitcoincore/values.yaml | 31 ++--- src/warnet/constants.py | 1 + src/warnet/deploy.py | 2 +- src/warnet/ln.py | 69 ++++++---- src/warnet/main.py | 2 + test/data/ln.graphml | 66 ---------- test/data/ln/network.yaml | 18 +++ test/data/ln/node-defaults.yaml | 4 + test/data/signet/node-defaults.yaml | 3 +- test/ln_basic_test.py | 121 ++++++++++++++++++ test/ln_test.py | 92 ++++++------- test/test_base.py | 7 - 24 files changed, 627 insertions(+), 178 deletions(-) create mode 100644 resources/charts/bitcoincore/charts/lnd/Chart.yaml create mode 100644 resources/charts/bitcoincore/charts/lnd/templates/_helpers.tpl create mode 100644 resources/charts/bitcoincore/charts/lnd/templates/configmap.yaml create mode 100644 resources/charts/bitcoincore/charts/lnd/templates/pod.yaml create mode 100644 resources/charts/bitcoincore/charts/lnd/templates/service.yaml create mode 100644 resources/charts/bitcoincore/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 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/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/bitcoincore/charts/lnd/Chart.yaml b/resources/charts/bitcoincore/charts/lnd/Chart.yaml new file mode 100644 index 000000000..b77eb714a --- /dev/null +++ b/resources/charts/bitcoincore/charts/lnd/Chart.yaml @@ -0,0 +1,25 @@ +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/bitcoincore/charts/lnd/templates/_helpers.tpl b/resources/charts/bitcoincore/charts/lnd/templates/_helpers.tpl new file mode 100644 index 000000000..de7c0c156 --- /dev/null +++ b/resources/charts/bitcoincore/charts/lnd/templates/_helpers.tpl @@ -0,0 +1,78 @@ +{{/* +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 "-" }}-ln +{{- 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 "-" }}-ln +{{- 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/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/bitcoincore/charts/lnd/templates/pod.yaml b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml new file mode 100644 index 000000000..618005b22 --- /dev/null +++ b/resources/charts/bitcoincore/charts/lnd/templates/pod.yaml @@ -0,0 +1,74 @@ +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 }} + chain: {{ .Values.global.chain }} +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/bitcoincore/charts/lnd/templates/service.yaml b/resources/charts/bitcoincore/charts/lnd/templates/service.yaml new file mode 100644 index 000000000..6b2bc404e --- /dev/null +++ b/resources/charts/bitcoincore/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/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml new file mode 100644 index 000000000..672769fae --- /dev/null +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -0,0 +1,121 @@ +# 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 + bitcoind.rpcuser=user + # zmq* and bitcoind.rpcpass are set in configmap.yaml + +config: "" + +defaultConfig: "" + +extraLabels: {} 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 d7076e6e9..56cd61958 100644 --- a/resources/charts/bitcoincore/templates/pod.yaml +++ b/resources/charts/bitcoincore/templates/pod.yaml @@ -7,9 +7,11 @@ metadata: {{- with .Values.podLabels }} {{- toYaml . | nindent 4 }} {{- end }} - chain: {{ .Values.chain }} - RPCPort: "{{ index .Values .Values.chain "RPCPort" }}" - 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" @@ -32,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 @@ -46,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: @@ -86,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/src/warnet/constants.py b/src/warnet/constants.py index f8b45443f..2c29448e8 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 2a429b89f..5001a118f 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -311,7 +311,7 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str): temp_override_file_path = "" try: 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" anad k != "lnd"} defaults_file_path = directory / DEFAULTS_FILE cmd = f"{HELM_COMMAND} {node_name} {BITCOIN_CHART_LOCATION} --namespace {namespace} -f {defaults_file_path}" diff --git a/src/warnet/ln.py b/src/warnet/ln.py index ade55759e..22ab86630 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -1,6 +1,10 @@ +import json +from typing import Optional + import click -from .rpc import rpc_call +from .k8s import get_default_namespace_or, get_pod +from .process import run_command @click.group(name="ln") @@ -9,31 +13,50 @@ 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("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 in [network] + Call lightning cli rpc on """ - print( - rpc_call( - "tank_lncli", - {"network": network, "node": node, "command": command}, - ) - ) + print(_rpc(pod, method, params, namespace)) -@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): +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 -n {namespace} exec {pod_name} -- lncli --network {chain} {method} {' '.join(map(str, params))}" + return run_command(cmd) + + +@ln.command() +@click.argument("pod", type=str) +def pubkey( + pod: str, +): + """ + Get lightning node pub key from + """ + # 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 pub key on in [network] + Get lightning node host 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") + uris = json.loads(info)["uris"] + if uris and len(uris) >= 0: + print(uris[0].split("@")[1]) 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..b917dc185 --- /dev/null +++ b/test/data/ln/network.yaml @@ -0,0 +1,18 @@ +nodes: + - name: tank-0000 + addnode: + - tank-0001 + ln: + lnd: true + + - name: tank-0001 + addnode: + - tank-0002 + ln: + lnd: true + + - name: tank-0002 + addnode: + - tank-0000 + ln: + lnd: true \ 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/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 new file mode 100755 index 000000000..20ec965c6 --- /dev/null +++ b/test/ln_basic_test.py @@ -0,0 +1,121 @@ +#!/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 wait_for_all_ln_rpc(): + nodes = ["tank-0000-ln", "tank-0001-ln", "tank-0002-ln"] + for node in nodes: + try: + self.warnet(f"ln rpc {node} getinfo") + except Exception: + print(f"LN node {node} 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' + + "}'" + ) + self.warnet("bitcoin rpc tank-0000 -generate 1") + + def manual_open_channels(self): + # 0 -> 1 -> 2 + 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-ln") + if not host2: + host2 = self.warnet("ln host tank-0002-ln") + sleep(1) + + print( + self.warnet( + f"ln rpc tank-0000-ln openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" + ) + ) + print( + self.warnet( + f"ln rpc tank-0001-ln 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-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"] + sleep(1) + + def pay_invoice(self): + inv = json.loads(self.warnet("ln rpc tank-0002-ln addinvoice --amt 1000")) + print(inv) + 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-ln channelbalance"))["balance"] == 1000 + self.wait_for_predicate(wait_for_success) + + +if __name__ == "__main__": + test = LNBasicTest() + test.run_test() 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 89009bf5f2a0d8932fc9a936b34544b42831b717 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 8 Nov 2024 12:26:04 -0500 Subject: [PATCH 02/16] 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 5001a118f..2a429b89f 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -311,7 +311,7 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str): temp_override_file_path = "" try: node_name = node.get("name") - node_config_override = {k: v for k, v in node.items() if k != "name" anad k != "lnd"} + node_config_override = {k: v for k, v in node.items() if k != "name"} defaults_file_path = directory / DEFAULTS_FILE cmd = f"{HELM_COMMAND} {node_name} {BITCOIN_CHART_LOCATION} --namespace {namespace} -f {defaults_file_path}" 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 df70c2aaf05a483ed46cb4b95d804cf86f2dd0cc Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 8 Nov 2024 15:26:32 -0500 Subject: [PATCH 03/16] 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 c38d7dcb6074d2813dd2a20c0c629e755149cf38 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Tue, 12 Nov 2024 13:45:38 -0500 Subject: [PATCH 04/16] 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 084370318a37dc8b3a35b7b82dc936b6e9979e10 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Thu, 14 Nov 2024 15:21:37 -0500 Subject: [PATCH 05/16] 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 bdf6fdbb33d5bbfda84e8345d0d12077e2d5b5ce Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 15 Nov 2024 13:55:55 -0500 Subject: [PATCH 06/16] 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 c2f45d62c..5f134125d 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -261,24 +261,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() @@ -352,8 +335,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 10f6ae210ea462a494dc1fdc773f7428f0b56abb Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 15 Nov 2024 14:26:48 -0500 Subject: [PATCH 07/16] 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 c3530f97d28cd74527285ada53e7222432cdf537 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 15 Nov 2024 15:46:57 -0500 Subject: [PATCH 08/16] 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 731c53d5857b157f47a9f0736a905961378112bd Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 18 Nov 2024 09:37:12 -0500 Subject: [PATCH 09/16] 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 6dc4024baf99e5cfcaeb57b3a5115b1f22345b96 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 20 Nov 2024 13:03:33 -0600 Subject: [PATCH 10/16] 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 fe1c2ee3cccd3796ee2d7939ad6de8db4d2a5d39 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Wed, 27 Nov 2024 09:09:25 -0500 Subject: [PATCH 11/16] lnd: slow down startup probe to create wallet --- resources/charts/bitcoincore/charts/lnd/values.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 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 From 5e69110642e9fff22f31527957eba77f2c7fec1c Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 2 Dec 2024 09:48:21 -0500 Subject: [PATCH 12/16] Move LND out of commander and refactor ln_init for new architecture --- resources/scenarios/commander.py | 58 +-- resources/scenarios/ln_framework/__init__.py | 0 resources/scenarios/ln_framework/ln.py | 174 +++++++ resources/scenarios/ln_init.py | 512 +++++++++++++------ src/warnet/control.py | 8 +- src/warnet/graph.py | 28 +- 6 files changed, 563 insertions(+), 217 deletions(-) create mode 100644 resources/scenarios/ln_framework/__init__.py create mode 100644 resources/scenarios/ln_framework/ln.py diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index ca7c16800..5684c4ce7 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -1,19 +1,18 @@ 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 from kubernetes import client, config +from ln_framework.ln import LND from test_framework.authproxy import AuthServiceProxy from test_framework.p2p import NetworkThread from test_framework.test_framework import ( @@ -24,13 +23,6 @@ from test_framework.test_node import TestNode from test_framework.util import PortSeed, get_rpc_proxy -# 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 - # Figure out what namespace we are in with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f: NAMESPACE = f.read().strip() @@ -55,6 +47,7 @@ "rpc_port": int(pod.metadata.labels["RPCPort"]), "rpc_user": "user", "rpc_password": pod.metadata.labels["rpcpassword"], + "init_peers": pod.metadata.annotations["init_peers"], } ) @@ -80,45 +73,6 @@ def auth_proxy_request(self, method, path, postdata): AuthServiceProxy._request = auth_proxy_request -class LND: - def __init__(self, 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") - - 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): @@ -139,6 +93,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 +154,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_framework/__init__.py b/resources/scenarios/ln_framework/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py new file mode 100644 index 000000000..8fcdc1bc7 --- /dev/null +++ b/resources/scenarios/ln_framework/ln.py @@ -0,0 +1,174 @@ +import http.client +import json +import ssl +import time + +# 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 + + +# https://github.com/lightningcn/lightning-rfc/blob/master/07-routing-gossip.md#the-channel_update-message +# We use the field names as written in the BOLT as our canonical, internal field names. +# In LND, Policy objects returned by DescribeGraph have completely different labels +# than policy objects expected by the UpdateChannelPolicy API, and neither +# of these are the names used in the BOLT... +class Policy: + def __init__( + self, + cltv_expiry_delta: int, + htlc_minimum_msat: int, + fee_base_msat: int, + fee_proportional_millionths: int, + htlc_maximum_msat: int, + ): + self.cltv_expiry_delta = cltv_expiry_delta + self.htlc_minimum_msat = htlc_minimum_msat + self.fee_base_msat = fee_base_msat + self.fee_proportional_millionths = fee_proportional_millionths + self.htlc_maximum_msat = htlc_maximum_msat + + @classmethod + def from_lnd_describegraph(cls, policy: dict): + return cls( + cltv_expiry_delta=int(policy.get("time_lock_delta")), + htlc_minimum_msat=int(policy.get("min_htlc")), + fee_base_msat=int(policy.get("fee_base_msat")), + fee_proportional_millionths=int(policy.get("fee_rate_milli_msat")), + htlc_maximum_msat=int(policy.get("max_htlc_msat")), + ) + + @classmethod + def from_dict(cls, policy: dict): + return cls( + cltv_expiry_delta=policy.get("cltv_expiry_delta"), + htlc_minimum_msat=policy.get("htlc_minimum_msat"), + fee_base_msat=policy.get("fee_base_msat"), + fee_proportional_millionths=policy.get("fee_proportional_millionths"), + htlc_maximum_msat=policy.get("htlc_maximum_msat"), + ) + + def to_dict(self): + return { + "cltv_expiry_delta": self.cltv_expiry_delta, + "htlc_minimum_msat": self.htlc_minimum_msat, + "fee_base_msat": self.fee_base_msat, + "fee_proportional_millionths": self.fee_proportional_millionths, + "htlc_maximum_msat": self.htlc_maximum_msat, + } + + def to_lnd_chanpolicy(self, capacity): + # LND requires a 1% reserve + reserve = ((capacity * 99) // 100) * 1000 + return { + "time_lock_delta": self.cltv_expiry_delta, + "min_htlc_msat": self.htlc_minimum_msat, + "base_fee_msat": self.fee_base_msat, + "fee_rate_ppm": self.fee_proportional_millionths, + "max_htlc_msat": min(self.htlc_maximum_msat, reserve), + "min_htlc_msat_specified": True, + } + + +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): + 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) + attempt = 0 + while True: + attempt += 1 + try: + 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: + 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, capacity, push_amt, fee_rate): + res = self.post( + "/v1/channels/stream", + data={ + "local_funding_amount": capacity, + "push_sat": push_amt, + "node_pubkey": pk, + "sat_per_vbyte": fee_rate, + }, + ) + return json.loads(res) + + def update(self, txid_hex: str, policy: dict, capacity: int): + ln_policy = Policy.from_dict(policy).to_lnd_chanpolicy(capacity) + data = {"chan_point": {"funding_txid_str": txid_hex, "output_index": 0}, **ln_policy} + res = self.post( + "/v1/chanpolicy", + # Policy objects returned by DescribeGraph have + # completely different labels than policy objects expected + # by the UpdateChannelPolicy API. + data=data, + ) + return json.loads(res) + + def graph(self): + res = self.get("/v1/graph") + return json.loads(res) diff --git a/resources/scenarios/ln_init.py b/resources/scenarios/ln_init.py index 82745a123..ac50c9b75 100644 --- a/resources/scenarios/ln_init.py +++ b/resources/scenarios/ln_init.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 +import threading from time import sleep from commander import Commander +from ln_framework.ln import Policy class LNInit(Commander): @@ -14,171 +16,389 @@ 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...") + + 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) + + conn_threads = [ + threading.Thread(target=tank_connected, args=(self, tank)) for tank in self.nodes + ] + for thread in conn_threads: + thread.start() - 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) + all(thread.join() is None for thread in conn_threads) + self.log.info("Network connected") - self.log.info("Fund LN wallets") + ## + # 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, 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 {ln.name}") + break + else: + self.log.info( + f"Couldn't get wallet address from {ln.name}:\n {res}\n wait and retry..." + ) + sleep(1) + + addr_threads = [ + threading.Thread(target=get_ln_addr, args=(self, ln)) for ln in self.lns.values() + ] + 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, ln): + bal = 0 + while True: + bal = ln.walletbalance() + if bal >= (split * 100000000): + self.log.info(f"LN node {ln.name} confirmed funds") + break + sleep(1) - self.wait_until(funded_lnnodes, timeout=5 * 60) + fund_threads = [ + threading.Thread(target=confirm_ln_balance, args=(self, ln)) for ln in self.lns.values() + ] + 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, ln): + uri = None + while True: + uri = ln.uri() + if uri: + ln_uris[ln.name] = uri + self.log.info(f"LN node {ln.name} has URI {uri}") + break + sleep(1) + + uri_threads = [ + threading.Thread(target=get_ln_uri, args=(self, ln)) for ln in self.lns.values() + ] + 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), + capacity=ch["capacity"], + 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, f"Actual block:{block_height}\n{ch}" + assert ( + block_txs[ch["id"]["index"]] == ch["txid"] + ), f"Actual txid:{block_txs[ch["id"]["index"]]}\n{ch}" + self.log.info("👍") + + gen(5) + self.log.info(f"Confirmed {len(self.channels)} total channel opens") + + self.log.info("Waiting for channel announcement gossip...") + + def ln_all_chs(self, ln): + expected = len(self.channels) + while len(ln.graph()["edges"]) != expected: + sleep(1) + self.log.info(f"LN {ln.name} has graph with all {expected} channels") + + ch_ann_threads = [ + threading.Thread(target=ln_all_chs, args=(self, ln)) for ln in self.lns.values() + ] + for thread in ch_ann_threads: + thread.start() + + all(thread.join() is None for thread in ch_ann_threads) + self.log.info("All LN nodes have complete graph") + + ## + # UPDATE CHANNEL POLICIES + ## + self.log.info("Updating channel policies...") + + def update_policy(self, ln, txid_hex, policy, capacity): + self.log.info(f"Sending update from {ln.name} for channel with outpoint: {txid_hex}:0") + res = ln.update(txid_hex, policy, capacity) + assert ( + len(res["failed_updates"]) == 0 + ), f" Failed updates: {res["failed_updates"]}\n txid: {txid_hex}\n policy:{policy}" + + update_threads = [] + for ch in self.channels: + ts = threading.Thread( + target=update_policy, + args=( + self, + self.lns[ch["source"]], + ch["txid"], + ch["source_policy"], + ch["capacity"], + ), + ) + ts.start() + update_threads.append(ts) + tt = threading.Thread( + target=update_policy, + args=( + self, + self.lns[ch["target"]], + ch["txid"], + ch["target_policy"], + ch["capacity"], + ), + ) + tt.start() + update_threads.append(tt) + count = len(update_threads) + + all(thread.join() is None for thread in update_threads) + self.log.info(f"Sent {count} channel policy updates") + + self.log.info("Waiting for all channel policy gossip to synchronize...") + + def policy_equal(pol1, pol2, capacity): + return pol1.to_lnd_chanpolicy(capacity) == pol2.to_lnd_chanpolicy(capacity) + + def matching_graph(self, expected, ln): + while True: + actual = ln.graph()["edges"] + assert len(expected) == len(actual) + done = True + for i, actual_ch in enumerate(actual): + expected_ch = expected[i] + capacity = expected_ch["capacity"] + # We assert this because it isn't updated as part of policy. + # If this fails we have a bigger issue + assert int(actual_ch["capacity"]) == capacity + # policy actual/expected source/target + polas = Policy.from_lnd_describegraph(actual_ch["node1_policy"]) + polat = Policy.from_lnd_describegraph(actual_ch["node2_policy"]) + poles = Policy(**expected_ch["source_policy"]) + polet = Policy(**expected_ch["target_policy"]) + # Allow policy swap when comparing channels + if policy_equal(polas, poles, capacity) and policy_equal( + polat, polet, capacity + ): + continue + if policy_equal(polas, polet, capacity) and policy_equal( + polat, poles, capacity + ): + continue + done = False + break + if done: + self.log.info(f"LN {ln.name} graph channel policies all match expected source") + break + else: + sleep(1) + + expected = sorted(self.channels, key=lambda ch: (ch["id"]["block"], ch["id"]["index"])) + policy_threads = [ + threading.Thread(target=matching_graph, args=(self, expected, ln)) + for ln in self.lns.values() + ] + for thread in policy_threads: + thread.start() + + all(thread.join() is None for thread in policy_threads) + self.log.info("All LN nodes have matching graph!") def main(): diff --git a/src/warnet/control.py b/src/warnet/control.py index 5f134125d..816f8aa37 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -272,7 +272,13 @@ def filter(path): return False if any( needle in str(path) - for needle in ["__init__.py", "commander.py", "test_framework", scenario_path.name] + for needle in [ + "__init__.py", + "commander.py", + "test_framework", + "ln_framework", + scenario_path.name, + ] ): print(f"Including: {path}") return True diff --git a/src/warnet/graph.py b/src/warnet/graph.py index 193b6d9f4..d06387710 100644 --- a/src/warnet/graph.py +++ b/src/warnet/graph.py @@ -9,6 +9,8 @@ import inquirer import yaml +from resources.scenarios.ln_framework.ln import Policy + from .constants import DEFAULT_TAG, SUPPORTED_TAGS @@ -257,23 +259,6 @@ def _import_network(graph_file_path, output_path): 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! @@ -281,14 +266,13 @@ 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, - "source_policy": import_policy(edge["node1_policy"]), - "target_policy": import_policy(edge["node2_policy"]), + "capacity": int(edge["capacity"]), + "push_amt": int(edge["capacity"]) // 2, + "source_policy": Policy.from_lnd_describegraph(edge["node1_policy"]).to_dict(), + "target_policy": Policy.from_lnd_describegraph(edge["node2_policy"]).to_dict(), } tanks[source]["lnd"]["channels"].append(channel) index += 1 From 9209a5e2e48b73a71fda953e0f448e5856781642 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 2 Dec 2024 11:12:45 -0500 Subject: [PATCH 13/16] test LN --- .github/workflows/test.yml | 1 + test/ln_test.py | 156 ++++++++++++++----------------------- 2 files changed, 59 insertions(+), 98 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e199ed4d..00716ef3e 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_test.py - ln_basic_test.py - rpc_test.py - services_test.py diff --git a/test/ln_test.py b/test/ln_test.py index 8efe0b385..1c1e046b6 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -6,127 +6,87 @@ from test_base import TestBase -from warnet.cli.process import run_command +from warnet.process import stream_command class LNTest(TestBase): def __init__(self): super().__init__() - self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln" + self.graph_file = Path(os.path.dirname(__file__)) / "data" / "LN_10.json" + self.imported_network_dir = self.tmpdir / "imported_network" + self.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" def run_test(self): try: + self.import_network() 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_payments() finally: self.cleanup() + def import_network(self): + self.log.info("Importing network graph from JSON...") + res = self.warnet(f"import-network {self.graph_file} {self.imported_network_dir}") + self.log.info(f"\n{res}") + def setup_network(self): - self.log.info("Setting up network") - self.log.info(self.warcli(f"network deploy {self.network_dir}")) + self.log.info("Setting up network...") + self.log.info(self.warnet(f"deploy {self.imported_network_dir}")) self.wait_for_all_tanks_status(target="running") - self.wait_for_all_edges() - - 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 tank-0000 getblockcount") - self.warnet("scenarios run ln_init") + stream_command(f"warnet run {self.scen_dir / 'ln_init.py'} --debug") self.wait_for_all_scenarios() def test_channel_policies(self): self.log.info("Ensuring node-level channel policy settings") - 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: - assert ( - chan["node2_policy"]["time_lock_delta"] == 33 - ), "Expected time_lock_delta to be 33" - - self.log.info("Ensuring no circuit breaker forwards yet") - 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 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 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 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("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 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 tank-0002-ln payinvoice -f {inv}")) - - 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 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 check_invoice_settled(self): - 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, 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"lnd {pod} has {settled} settled invoices") - return settled + graphs = [] + for n in range(10): + ln = f"tank-{n:04d}-ln" + res = self.warnet(f"ln rpc {ln} describegraph") + graphs.append(json.loads(res)["edges"]) + + def check_policy(node: int, index: int, field: str, values: tuple): + self.log.info(f"Checking policy: Node={node} ch={index} Expected={field}:{values}") + graph = graphs[node] + assert len(graph) == 13 + ch = graph[index] + a = int(ch["node1_policy"][field]) + b = int(ch["node2_policy"][field]) + assert values == (a, b) or values == ( + b, + a, + ), f"policy check failed:\nActual:\n{ch}\nExpected:\n{field}:{values}" + + # test one property of one channel from each node + check_policy(0, 0, "fee_base_msat", (250, 1000)) + check_policy(1, 1, "time_lock_delta", (40, 100)) + check_policy(2, 2, "fee_rate_milli_msat", (1, 4000)) + check_policy(3, 3, "fee_rate_milli_msat", (499, 4000)) + check_policy(4, 4, "time_lock_delta", (40, 144)) + check_policy(5, 5, "max_htlc_msat", (1980000000, 1500000000)) + check_policy(6, 6, "fee_rate_milli_msat", (550, 71)) + check_policy(7, 7, "min_htlc", (1000, 1)) + check_policy(8, 8, "time_lock_delta", (80, 144)) + check_policy(9, 9, "fee_base_msat", (616, 1000)) + + def test_payments(self): + def get_and_pay(src, tgt): + src = f"tank-{src:04d}-ln" + tgt = f"tank-{tgt:04d}-ln" + invoice = json.loads(self.warnet(f"ln rpc {tgt} addinvoice --amt 230118"))[ + "payment_request" + ] + print(self.warnet(f"ln rpc {src} payinvoice {invoice} --force")) + + get_and_pay(0, 5) + get_and_pay(2, 3) + get_and_pay(1, 9) + get_and_pay(8, 7) + get_and_pay(4, 6) if __name__ == "__main__": From acac14bf64e63259b3dbf2030a670a162a606416 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Thu, 5 Dec 2024 13:05:16 -0500 Subject: [PATCH 14/16] make ln_init part of deploy() if ln channels are defined --- .github/workflows/test.yml | 1 - resources/scenarios/ln_init.py | 50 ++++++----- src/warnet/control.py | 10 +++ src/warnet/deploy.py | 21 +++++ src/warnet/ln.py | 28 ------ test/data/ln/network.yaml | 4 +- test/ln_basic_test.py | 159 --------------------------------- test/ln_test.py | 9 +- 8 files changed, 60 insertions(+), 222 deletions(-) delete mode 100755 test/ln_basic_test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00716ef3e..86d6b03d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,6 @@ jobs: - graph_test.py - logging_test.py - ln_test.py - - ln_basic_test.py - rpc_test.py - services_test.py - signet_test.py diff --git a/resources/scenarios/ln_init.py b/resources/scenarios/ln_init.py index ac50c9b75..197a8c028 100644 --- a/resources/scenarios/ln_init.py +++ b/resources/scenarios/ln_init.py @@ -322,30 +322,32 @@ def update_policy(self, ln, txid_hex, policy, capacity): update_threads = [] for ch in self.channels: - ts = threading.Thread( - target=update_policy, - args=( - self, - self.lns[ch["source"]], - ch["txid"], - ch["source_policy"], - ch["capacity"], - ), - ) - ts.start() - update_threads.append(ts) - tt = threading.Thread( - target=update_policy, - args=( - self, - self.lns[ch["target"]], - ch["txid"], - ch["target_policy"], - ch["capacity"], - ), - ) - tt.start() - update_threads.append(tt) + if "source_policy" in ch: + ts = threading.Thread( + target=update_policy, + args=( + self, + self.lns[ch["source"]], + ch["txid"], + ch["source_policy"], + ch["capacity"], + ), + ) + ts.start() + update_threads.append(ts) + if "target_policy" in ch: + tt = threading.Thread( + target=update_policy, + args=( + self, + self.lns[ch["target"]], + ch["txid"], + ch["target_policy"], + ch["capacity"], + ), + ) + tt.start() + update_threads.append(tt) count = len(update_threads) all(thread.join() is None for thread in update_threads) diff --git a/src/warnet/control.py b/src/warnet/control.py index 816f8aa37..d26614a48 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -252,6 +252,16 @@ def run( Run a scenario from a file. Pass `-- --help` to get individual scenario help """ + return _run(scenario_file, debug, source_dir, additional_args, namespace) + + +def _run( + scenario_file: str, + debug: bool, + source_dir, + additional_args: tuple[str], + namespace: Optional[str], +): namespace = get_default_namespace_or(namespace) scenario_path = Path(scenario_file).resolve() diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 2a429b89f..f0c53fa64 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -22,8 +22,10 @@ NAMESPACES_CHART_LOCATION, NAMESPACES_FILE, NETWORK_FILE, + SCENARIOS_DIR, WARGAMES_NAMESPACE_PREFIX, ) +from .control import _run from .k8s import ( get_default_namespace, get_default_namespace_or, @@ -295,6 +297,16 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str with network_file_path.open() as f: network_file = yaml.safe_load(f) + needs_ln_init = False + for node in network_file["nodes"]: + if ( + "lnd" in node + and "channels" in node["lnd"] + and len(node["lnd"]["channels"]) > 0 + ): + needs_ln_init = True + break + processes = [] for node in network_file["nodes"]: p = Process(target=deploy_single_node, args=(node, directory, debug, namespace)) @@ -304,6 +316,15 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str for p in processes: p.join() + if needs_ln_init: + _run( + scenario_file=SCENARIOS_DIR / "ln_init.py", + debug=True, + source_dir=SCENARIOS_DIR, + additional_args=None, + namespace=namespace, + ) + def deploy_single_node(node, directory: Path, debug: bool, namespace: str): defaults_file_path = directory / DEFAULTS_FILE diff --git a/src/warnet/ln.py b/src/warnet/ln.py index 6296cf6ed..a1f7c1eb2 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -4,7 +4,6 @@ import click from .k8s import ( - get_channels, get_default_namespace_or, get_pod, ) @@ -70,30 +69,3 @@ def _host(pod): 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 d1c135242..b125533c8 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -30,7 +30,7 @@ nodes: block: 300 index: 1 target: tank-0004-ln - local_amt: 100000 + capacity: 100000 push_amt: 50000 - name: tank-0004 @@ -44,7 +44,7 @@ nodes: block: 300 index: 2 target: tank-0005-ln - local_amt: 50000 + capacity: 50000 push_amt: 25000 - name: tank-0005 diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py deleted file mode 100755 index 20159cf2a..000000000 --- a/test/ln_basic_test.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/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.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" - 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 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 using warnet RPC - self.automatic_open_channels() - 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() - - 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.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(): - for ln in self.lns: - try: - self.warnet(f"ln rpc {ln} getinfo") - except Exception: - 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): - 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 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") - pk2 = self.warnet("ln pubkey tank-0002-ln") - - host1 = "" - host2 = "" - - while not host1 or not host2: - if not host1: - host1 = self.warnet("ln host tank-0001-ln") - if not host2: - host2 = self.warnet("ln host tank-0002-ln") - sleep(1) - - print( - self.warnet( - f"ln rpc tank-0000-ln openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" - ) - ) - print( - self.warnet( - f"ln rpc tank-0001-ln openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" - ) - ) - - self.wait_for_two_txs() - - self.warnet("bitcoin rpc tank-0000 -generate 10") - - 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, 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 {sender} payinvoice -f {inv['payment_request']}")) - - def 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): - # 3 -> 4 -> 5 - self.warnet("ln open-all-channels") - - self.wait_for_two_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() - test.run_test() diff --git a/test/ln_test.py b/test/ln_test.py index 1c1e046b6..9a5b7c136 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -20,7 +20,6 @@ def run_test(self): try: self.import_network() self.setup_network() - self.run_ln_init_scenario() self.test_channel_policies() self.test_payments() finally: @@ -33,13 +32,7 @@ def import_network(self): def setup_network(self): self.log.info("Setting up network...") - self.log.info(self.warnet(f"deploy {self.imported_network_dir}")) - self.wait_for_all_tanks_status(target="running") - - def run_ln_init_scenario(self): - self.log.info("Running LN Init scenario") - stream_command(f"warnet run {self.scen_dir / 'ln_init.py'} --debug") - self.wait_for_all_scenarios() + stream_command(f"warnet deploy {self.imported_network_dir}") def test_channel_policies(self): self.log.info("Ensuring node-level channel policy settings") From e7c1628f15c4dc42bac7a43b1c6ba38e884ae052 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Sat, 21 Dec 2024 22:50:21 -0500 Subject: [PATCH 15/16] allow undefined channel policy --- .github/workflows/test.yml | 1 + resources/scenarios/ln_init.py | 5 + .../scenarios/test_scenarios/ln_basic.py | 42 ------ test/ln_basic_test.py | 126 ++++++++++++++++++ 4 files changed, 132 insertions(+), 42 deletions(-) delete mode 100644 resources/scenarios/test_scenarios/ln_basic.py create mode 100755 test/ln_basic_test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86d6b03d1..890696754 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 - ln_test.py - rpc_test.py - services_test.py diff --git a/resources/scenarios/ln_init.py b/resources/scenarios/ln_init.py index 197a8c028..9c2f0588e 100644 --- a/resources/scenarios/ln_init.py +++ b/resources/scenarios/ln_init.py @@ -369,6 +369,11 @@ def matching_graph(self, expected, ln): # We assert this because it isn't updated as part of policy. # If this fails we have a bigger issue assert int(actual_ch["capacity"]) == capacity + + # Policies were not defined in network.yaml + if "source_policy" not in expected_ch or "target_policy" not in expected_ch: + continue + # policy actual/expected source/target polas = Policy.from_lnd_describegraph(actual_ch["node1_policy"]) polat = Policy.from_lnd_describegraph(actual_ch["node2_policy"]) diff --git a/resources/scenarios/test_scenarios/ln_basic.py b/resources/scenarios/test_scenarios/ln_basic.py deleted file mode 100644 index 773ffd357..000000000 --- a/resources/scenarios/test_scenarios/ln_basic.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/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.lns["tank-0003-ln"].get("/v1/getinfo")) - uri = info["uris"][0] - pk3, host = uri.split("@") - - print( - self.lns["tank-0002-ln"].post("/v1/peers", data={"addr": {"pubkey": pk3, "host": host}}) - ) - - print( - self.lns["tank-0002-ln"].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/ln_basic_test.py b/test/ln_basic_test.py new file mode 100755 index 000000000..fdb479dbd --- /dev/null +++ b/test/ln_basic_test.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path +from time import sleep + +from test_base import TestBase + +from warnet.process import stream_command + + +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", + "tank-0002-ln", + "tank-0003-ln", + "tank-0004-ln", + "tank-0005-ln", + ] + + def run_test(self): + try: + # Wait for all nodes to wake up. ln_init will start automatically + self.setup_network() + + # Send a payment across channels opened automatically by ln_init + self.pay_invoice(sender="tank-0005-ln", recipient="tank-0003-ln") + + # Manually open two more channels between first three nodes + # and send a payment using warnet RPC + self.manual_open_channels() + self.wait_for_gossip_sync(self.lns[:3], 2 + 2) + self.pay_invoice(sender="tank-0000-ln", recipient="tank-0002-ln") + + finally: + self.cleanup() + + def setup_network(self): + self.log.info("Setting up network") + stream_command(f"warnet deploy {self.network_dir}") + + def fund_wallets(self): + 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 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") + pk2 = self.warnet("ln pubkey tank-0002-ln") + + host1 = "" + host2 = "" + + while not host1 or not host2: + if not host1: + host1 = self.warnet("ln host tank-0001-ln") + if not host2: + host2 = self.warnet("ln host tank-0002-ln") + sleep(1) + + print( + self.warnet( + f"ln rpc tank-0000-ln openchannel --node_key {pk1} --local_amt 100000 --connect {host1}" + ) + ) + print( + self.warnet( + f"ln rpc tank-0001-ln openchannel --node_key {pk2} --local_amt 100000 --connect {host2}" + ) + ) + + self.wait_for_two_txs() + + self.warnet("bitcoin rpc tank-0000 -generate 10") + + 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, 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 {sender} payinvoice -f {inv['payment_request']}")) + + def 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 scenario_open_channels(self): + # 2 -> 3 + # connecting all six ln nodes in the graph + scenario_file = self.scen_dir / "test_scenarios" / "ln_init.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() + test.run_test() From c18298d2bde972f12e58095a277d8d38195b0bf6 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Sat, 21 Dec 2024 23:18:27 -0500 Subject: [PATCH 16/16] lint --- src/warnet/deploy.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index f0c53fa64..455c00cac 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -299,11 +299,7 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str needs_ln_init = False for node in network_file["nodes"]: - if ( - "lnd" in node - and "channels" in node["lnd"] - and len(node["lnd"]["channels"]) > 0 - ): + if "lnd" in node and "channels" in node["lnd"] and len(node["lnd"]["channels"]) > 0: needs_ln_init = True break