diff --git a/resources/charts/bitcoincore/Chart.yaml b/resources/charts/bitcoincore/Chart.yaml index 4feb6e32e..36f7498af 100644 --- a/resources/charts/bitcoincore/Chart.yaml +++ b/resources/charts/bitcoincore/Chart.yaml @@ -6,6 +6,9 @@ dependencies: - name: lnd version: 0.1.0 condition: ln.lnd + - name: cln + version: 0.1.0 + condition: ln.cln # A chart can be either an 'application' or a 'library' chart. # diff --git a/resources/charts/bitcoincore/charts/cln/.helmignore b/resources/charts/bitcoincore/charts/cln/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/resources/charts/bitcoincore/charts/cln/Chart.yaml b/resources/charts/bitcoincore/charts/cln/Chart.yaml new file mode 100644 index 000000000..79eeda983 --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: cln +description: A Helm chart for CLN + +# 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/cln/templates/_helpers.tpl b/resources/charts/bitcoincore/charts/cln/templates/_helpers.tpl new file mode 100644 index 000000000..d983355cf --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/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 "cln.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 "cln.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 "cln.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "cln.labels" -}} +helm.sh/chart: {{ include "cln.chart" . }} +{{ include "cln.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "cln.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cln.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "cln.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cln.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/resources/charts/bitcoincore/charts/cln/templates/configmap.yaml b/resources/charts/bitcoincore/charts/cln/templates/configmap.yaml new file mode 100644 index 000000000..c69ac3c5d --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cln.fullname" . }} + labels: + {{- include "cln.labels" . | nindent 4 }} +data: + config: | + {{- .Values.baseConfig | nindent 4 }} + {{- .Values.defaultConfig | nindent 4 }} + {{- .Values.config | nindent 4 }} + network={{ .Values.global.chain }} + addr=0.0.0.0:{{ .Values.P2PPort }} + bitcoin-rpcconnect={{ include "bitcoincore.fullname" . }} + bitcoin-rpcport={{ index .Values.global .Values.global.chain "RPCPort" }} + bitcoin-rpcpassword={{ .Values.global.rpcpassword }} + alias={{ include "cln.fullname" . }} + announce-addr=dns:{{ include "cln.fullname" . }}:{{ .Values.P2PPort }} + database-upgrade=true + bitcoin-retry-timeout=600 + grpc-port={{ .Values.RPCPort }} + grpc-host=0.0.0.0 + clnrest-host=0.0.0.0 + clnrest-port=3010 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cln.fullname" . }}-channels + labels: + channels: "true" + {{- include "cln.labels" . | nindent 4 }} +data: + source: {{ include "cln.fullname" . }} + channels: | + {{ .Values.channels | toJson }} diff --git a/resources/charts/bitcoincore/charts/cln/templates/pod.yaml b/resources/charts/bitcoincore/charts/cln/templates/pod.yaml new file mode 100644 index 000000000..b2d651173 --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/templates/pod.yaml @@ -0,0 +1,100 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "cln.fullname" . }} + labels: + {{- include "cln.labels" . | nindent 4 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + app: {{ include "cln.fullname" . }} + {{- if .Values.collectLogs }} + collect_logs: "true" + {{- end }} + chain: {{ .Values.global.chain }} + annotations: + kubectl.kubernetes.io/default-container: "cln" +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: p2p + containerPort: {{ .Values.P2PPort }} + protocol: TCP + - name: rpc + containerPort: {{ .Values.RPCPort }} + protocol: TCP + - name: rest + containerPort: {{ .Values.RestPort }} + protocol: TCP + command: + - /bin/sh + - -c + - | + lightningd --conf=/root/.lightning/config & + sleep 1 + lightning-cli createrune > /working/rune.json + echo "Here is the rune file contents" + cat /working/rune.json + wait + 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: + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + - mountPath: /root/.lightning/config + name: config + subPath: config + {{- with .Values.extraContainers }} + {{- toYaml . | nindent 4 }} + {{- end }} + - name: http-server + image: busybox + command: ["/bin/sh", "-c"] + args: + - | + echo "Starting HTTP server..." + busybox httpd -f -p 8080 -h /working + ports: + - containerPort: 8080 + name: http + volumeMounts: + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- with .Values.volumes }} + {{- toYaml . | nindent 4 }} + {{- end }} + - configMap: + name: {{ include "cln.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/cln/templates/service.yaml b/resources/charts/bitcoincore/charts/cln/templates/service.yaml new file mode 100644 index 000000000..565f50182 --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/templates/service.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cln.fullname" . }} + labels: + {{- include "cln.labels" . | nindent 4 }} + app: {{ include "cln.fullname" . }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.P2PPort }} + targetPort: p2p + protocol: TCP + name: p2p + - port: {{ .Values.RPCPort }} + targetPort: rpc + protocol: TCP + name: rpc + - port: {{ .Values.RestPort }} + targetPort: rest + protocol: TCP + name: rest + selector: + {{- include "cln.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/bitcoincore/charts/cln/templates/servicemonitor.yaml b/resources/charts/bitcoincore/charts/cln/templates/servicemonitor.yaml new file mode 100644 index 000000000..4565a17d1 --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/templates/servicemonitor.yaml @@ -0,0 +1,15 @@ +{{- if .Values.metricsExport }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "cln.fullname" . }} + labels: + app.kubernetes.io/name: cln-metrics + release: prometheus +spec: + endpoints: + - port: prometheus-metrics + selector: + matchLabels: + app: {{ include "cln.fullname" . }} +{{- end }} diff --git a/resources/charts/bitcoincore/charts/cln/values.yaml b/resources/charts/bitcoincore/charts/cln/values.yaml new file mode 100644 index 000000000..d34144100 --- /dev/null +++ b/resources/charts/bitcoincore/charts/cln/values.yaml @@ -0,0 +1,102 @@ +# Default values for cln. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +namespace: warnet + +image: + repository: elementsproject/lightningd + pullPolicy: IfNotPresent + tag: "v25.02" + +nameOverride: "" +fullnameOverride: "" + +podAnnotations: {} +podLabels: + app: "warnet" + mission: "lightning" + +podSecurityContext: {} + +securityContext: {} + +service: + type: ClusterIP + +P2PPort: 9735 +RPCPort: 9736 +RestPort: 3010 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + +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: + - "/bin/sh" + - "-c" + - "lightning-cli getinfo >/dev/null 2>&1" + failureThreshold: 3 + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 +readinessProbe: + failureThreshold: 10 + periodSeconds: 30 + successThreshold: 1 + timeoutSeconds: 60 + exec: + command: + - "/bin/sh" + - "-c" + - "lightning-cli getinfo 2>/dev/null | grep -q 'id' || exit 1" + +# Additional volumes on the output Deployment definition. +volumes: + - name: working + emptyDir: {} + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: + - name: working + mountPath: "/working" + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +baseConfig: | + log-level=info + developer + dev-fast-gossip + bitcoin-rpcuser=user + # bitcoind.rpcpass are set in configmap.yaml + +config: "" + +defaultConfig: "" + +channels: [] diff --git a/resources/charts/bitcoincore/values.yaml b/resources/charts/bitcoincore/values.yaml index 8c9f3215f..67f1b9b10 100644 --- a/resources/charts/bitcoincore/values.yaml +++ b/resources/charts/bitcoincore/values.yaml @@ -141,4 +141,5 @@ loadSnapshot: url: "" ln: - lnd: false \ No newline at end of file + lnd: false + cln: false diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index f6b24ef92..a627813af 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -19,8 +19,18 @@ SimLN also requires access details for each node; however, the SimLN plugin will "cert": <path_to_tls_cert> } ```` +SimLN plugin also supports Core Lightning (CLN). CLN nodes connection details are transfered from the CLN node to SimLN node during launch-activity processing. +```` JSON +{ + "id": <node_id>, + "address": https://<domain:port>, + "ca_cert": /working/<node_id>-ca.pem, + "client_cert": /working/<node_id>-client.pem, + "client_key": /working/<node_id>-client-key.pem +} +```` -Since SimLN already has access to those LND connection details, it means you can focus on the "activity" definitions. +Since SimLN already has access to those LND and CLN connection details, it means you can focus on the "activity" definitions. ### Launch activity definitions from the command line The SimLN plugin takes "activity" definitions like so: @@ -73,8 +83,8 @@ nodes: addnode: - tank-0000 ln: - lnd: true - lnd: + cln: true + cln: channels: - id: block: 300 diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 93c217f32..78df1a917 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -11,6 +11,7 @@ from warnet.constants import LIGHTNING_MISSION, PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent from warnet.k8s import ( + copyfile, download, get_default_namespace, get_mission, @@ -145,6 +146,8 @@ def _launch_activity(activity: Optional[list[dict]], plugin_dir: str) -> str: activity_json = _generate_activity_json(activity) wait_for_init(name, namespace=get_default_namespace(), quiet=True) + # write cert files to container + transfer_cln_certs(name) if write_file_to_container( name, "init", @@ -162,13 +165,18 @@ def _generate_activity_json(activity: Optional[list[dict]]) -> str: nodes = [] for i in get_mission(LIGHTNING_MISSION): - name = i.metadata.name - node = { - "id": name, - "address": f"https://{name}:10009", - "macaroon": "/working/admin.macaroon", - "cert": "/working/tls.cert", - } + ln_name = i.metadata.name + port = 10009 + node = {"id": ln_name} + if "cln" in i.metadata.labels["app.kubernetes.io/name"]: + port = 9736 + node["ca_cert"] = f"/working/{ln_name}-ca.pem" + node["client_cert"] = f"/working/{ln_name}-client.pem" + node["client_key"] = f"/working/{ln_name}-client-key.pem" + else: + node["macaroon"] = "/working/admin.macaroon" + node["cert"] = "/working/tls.cert" + node["address"] = f"https://{ln_name}:{port}" nodes.append(node) if activity: @@ -179,6 +187,39 @@ def _generate_activity_json(activity: Optional[list[dict]]) -> str: return json.dumps(data, indent=2) +def transfer_cln_certs(name): + dst_container = "init" + for i in get_mission(LIGHTNING_MISSION): + ln_name = i.metadata.name + if "cln" in i.metadata.labels["app.kubernetes.io/name"]: + chain = i.metadata.labels["chain"] + cln_root = f"/root/.lightning/{chain}" + copyfile( + ln_name, + "cln", + f"{cln_root}/ca.pem", + name, + dst_container, + f"/working/{ln_name}-ca.pem", + ) + copyfile( + ln_name, + "cln", + f"{cln_root}/client.pem", + name, + dst_container, + f"/working/{ln_name}-client.pem", + ) + copyfile( + ln_name, + "cln", + f"{cln_root}/client-key.pem", + name, + dst_container, + f"/working/{ln_name}-client-key.pem", + ) + + def _sh(pod, method: str, params: tuple[str, ...]) -> str: namespace = get_default_namespace() diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index 240d9c61c..4a44ab4a7 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -13,8 +13,7 @@ from time import sleep from kubernetes import client, config -from ln_framework.ln import LND - +from ln_framework.ln import CLN, LND, LNNode from test_framework.authproxy import AuthServiceProxy from test_framework.p2p import NetworkThread from test_framework.test_framework import ( @@ -71,7 +70,10 @@ ) if pod.metadata.labels["mission"] == "lightning": - WARNET["lightning"].append(pod.metadata.name) + lnnode = LND(pod.metadata.name, pod.status.pod_ip) + if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: + lnnode = CLN(pod.metadata.name, pod.status.pod_ip) + WARNET["lightning"].append(lnnode) for cm in cmaps.items: if not cm.metadata.labels or "channels" not in cm.metadata.labels: @@ -92,6 +94,30 @@ def auth_proxy_request(self, method, path, postdata): AuthServiceProxy._request = auth_proxy_request +# Create a custom formatter +class ColorFormatter(logging.Formatter): + """Custom formatter to add color based on log level.""" + + # Define ANSI color codes + RED = "\033[91m" + YELLOW = "\033[93m" + GREEN = "\033[92m" + RESET = "\033[0m" + + FORMATS = { + logging.DEBUG: f"{RESET}%(name)-8s - Thread-%(thread)d - %(message)s{RESET}", + logging.INFO: f"{RESET}%(name)-8s - %(message)s{RESET}", + logging.WARNING: f"{YELLOW}%(name)-8s - %(message)s{RESET}", + logging.ERROR: f"{RED}%(name)-8s - %(message)s{RESET}", + logging.CRITICAL: f"{RED}##%(name)-8s - %(message)s##{RESET}", + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + class Commander(BitcoinTestFramework): # required by subclasses of BitcoinTestFramework def set_test_params(self): @@ -132,7 +158,7 @@ def tank_connected(self, tank): if count >= tank.init_peers: break else: - sleep(1) + sleep(5) conn_threads = [ threading.Thread(target=tank_connected, args=(self, tank)) for tank in self.nodes @@ -164,13 +190,12 @@ def setup(self): # Scenarios log directly to stdout which gets picked up by the # subprocess manager in the server, and reprinted to the global log. ch = logging.StreamHandler(sys.stdout) - formatter = logging.Formatter(fmt="%(name)-8s %(message)s") - ch.setFormatter(formatter) + ch.setFormatter(ColorFormatter()) self.log.addHandler(ch) # Keep a separate index of tanks by pod name self.tanks: dict[str, TestNode] = {} - self.lns: dict[str, LND] = {} + self.lns: dict[str, LNNode] = {} self.channels = WARNET["channels"] for i, tank in enumerate(WARNET["tanks"]): @@ -203,7 +228,7 @@ def setup(self): self.tanks[tank["tank"]] = node for ln in WARNET["lightning"]: - self.lns[ln] = LND(ln) + self.lns[ln.name] = ln self.num_nodes = len(self.nodes) diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 8fcdc1bc7..f2ec4cbc2 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -1,7 +1,12 @@ +import base64 import http.client import json +import logging import ssl -import time +from abc import ABC, abstractmethod +from time import sleep + +import requests # hard-coded deterministic lnd credentials ADMIN_MACAROON_HEX = "0201036c6e6402f801030a1062beabbf2a614b112128afa0c0b4fdd61201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620b17be53e367290871681055d0de15587f6d1cd47d1248fe2662ae27f62cfbdc6" @@ -73,41 +78,331 @@ def to_lnd_chanpolicy(self, capacity): } -class LND: - def __init__(self, pod_name): +class LNNode(ABC): + @abstractmethod + def __init__(self, pod_name, ip_address): self.name = pod_name + self.ip_address = ip_address + self.log = logging.getLogger(pod_name) + handler = logging.StreamHandler() + formatter = logging.Formatter("%(name)-8s - %(levelname)s: %(message)s") + handler.setFormatter(formatter) + self.log.addHandler(handler) + self.log.setLevel(logging.INFO) + + @staticmethod + 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() + + @abstractmethod + def newaddress(self, max_tries=10) -> tuple[bool, str]: + pass + + @abstractmethod + def uri(self) -> str: + pass + + @abstractmethod + def walletbalance(self) -> int: + pass + + @abstractmethod + def connect(self, target_uri) -> dict: + pass + + @abstractmethod + def channel(self, pk, capacity, push_amt, fee_rate) -> dict: + pass + + @abstractmethod + def graph(self) -> dict: + pass + + @abstractmethod + def update(self, txid_hex: str, policy: dict, capacity: int) -> dict: + pass + + +class CLN(LNNode): + def __init__(self, pod_name, ip_address): + super().__init__(pod_name, ip_address) + self.conn = None + self.headers = {} + self.impl = "cln" + self.reset_connection() + + def reset_connection(self): + self.conn = http.client.HTTPSConnection( + host=self.name, port=3010, timeout=5, context=INSECURE_CONTEXT + ) + + def setRune(self, rune): + self.headers = {"Rune": rune} + + def get(self, uri): + attempt = 0 + while True: + try: + self.log.warning(f"headers: {self.headers}") + self.conn.request( + method="GET", + url=uri, + headers=self.headers, + ) + return self.conn.getresponse().read().decode("utf8") + except Exception as e: + self.reset_connection() + attempt += 1 + if attempt > 5: + self.log.error(f"Error CLN GET, Abort: {e}") + return None + sleep(1) + + def post(self, uri, data=None): + if not data: + data = {} + body = json.dumps(data) + post_header = self.headers + post_header["Content-Length"] = str(len(body)) + post_header["Content-Type"] = "application/json" + attempt = 0 + while True: + try: + self.conn.request( + method="POST", + url=uri, + body=body, + headers=post_header, + ) + # 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 as e: + self.reset_connection() + attempt += 1 + if attempt > 5: + self.log.error(f"Error CLN POST, Abort: {e}") + return None + sleep(1) + + def createrune(self, max_tries=2): + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = requests.get(f"http://{self.ip_address}:8080/rune.json", timeout=5).text + if not response: + sleep(2) + continue + self.log.debug(response) + res = json.loads(response) + self.setRune(res["rune"]) + return + raise Exception(f"Unable to fetch rune from {self.name}") + + def newaddress(self, max_tries=2): + self.createrune() + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/v1/newaddr") + if not response: + sleep(2) + continue + res = json.loads(response) + if "bech32" in res: + return True, res["bech32"] + else: + self.log.warning( + f"Couldn't get wallet address from {self.name}:\n {res}\n wait and retry..." + ) + sleep(2) + return False, "" + + def uri(self): + res = json.loads(self.post("/v1/getinfo")) + if len(res["address"]) < 1: + return None + return f"{res['id']}@{res['address'][0]['address']}:{res['address'][0]['port']}" + + def walletbalance(self, max_tries=2) -> int: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/v1/listfunds") + if not response: + sleep(2) + continue + res = json.loads(response) + return int(sum(o["amount_msat"] for o in res["outputs"]) / 1000) + return 0 + + def channelbalance(self, max_tries=2) -> int: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/v1/listfunds") + if not response: + sleep(2) + continue + res = json.loads(response) + return int(sum(o["our_amount_msat"] for o in res["channels"]) / 1000) + return 0 + + def connect(self, target_uri, max_tries=5) -> dict: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/v1/connect", {"id": target_uri}) + if response: + res = json.loads(response) + if "id" in res: + return {} + elif "code" in res and res["code"] == 402: + self.log.warning(f"failed connect 402: {response}, wait and retry...") + sleep(5) + else: + return res + else: + self.log.debug(f"connect response: {response}, wait and retry...") + sleep(2) + return None + + def channel(self, pk, capacity, push_amt, fee_rate, max_tries=5) -> dict: + data = { + "amount": capacity, + "push_msat": push_amt, + "id": pk, + "feerate": fee_rate, + } + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/v1/fundchannel", data) + if response: + res = json.loads(response) + if "txid" in res: + return {"txid": res["txid"], "outpoint": f"{res['txid']}:{res['outnum']}"} + else: + self.log.warning(f"unable to open channel: {res}, wait and retry...") + sleep(1) + else: + self.log.debug(f"channel response: {response}, wait and retry...") + sleep(2) + return None + + def createinvoice(self, sats, label, description="new invoice") -> str: + response = self.post( + "invoice", {"amount_msat": sats * 1000, "label": label, "description": description} + ) + if response: + res = json.loads(response) + return res["bolt11"] + return None + + def payinvoice(self, payment_request) -> str: + response = self.post("/v1/pay", {"bolt11": payment_request}) + if response: + res = json.loads(response) + if "code" in res: + return res["message"] + else: + return res["payment_hash"] + return None + + def graph(self, max_tries=2) -> dict: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/v1/listchannels") + if response: + res = json.loads(response) + if "channels" in res: + # Map to desired output + filtered_channels = [ch for ch in res["channels"] if ch["direction"] == 1] + # Sort by short_channel_id - block -> index -> output + sorted_channels = sorted(filtered_channels, key=lambda x: x["short_channel_id"]) + # Add capacity by dividing amount_msat by 1000 + for channel in sorted_channels: + channel["capacity"] = channel["amount_msat"] // 1000 + return {"edges": sorted_channels} + else: + self.log.warning(f"unable to open channel: {res}, wait and retry...") + sleep(1) + else: + self.log.debug(f"channel response: {response}, wait and retry...") + sleep(2) + return None + + def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dict: + self.log.warning("Channel Policy Updates not supported by CLN yet!") + return None + + +class LND(LNNode): + def __init__(self, pod_name, ip_address): + super().__init__(pod_name, ip_address) self.conn = http.client.HTTPSConnection( host=pod_name, port=8080, timeout=5, context=INSECURE_CONTEXT ) + self.headers = { + "Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX, + "Connection": "close", + } + self.impl = "lnd" + + def reset_connection(self): + self.conn = http.client.HTTPSConnection( + host=self.name, port=8080, timeout=5, context=INSECURE_CONTEXT + ) def get(self, uri): + attempt = 0 while True: try: self.conn.request( method="GET", url=uri, - headers={"Grpc-Metadata-macaroon": ADMIN_MACAROON_HEX, "Connection": "close"}, + headers=self.headers, ) return self.conn.getresponse().read().decode("utf8") - except Exception: - time.sleep(1) + except Exception as e: + self.reset_connection() + attempt += 1 + if attempt > 5: + self.log.error(f"Error LND POST, Abort: {e}") + return None + sleep(1) def post(self, uri, data): body = json.dumps(data) + post_header = self.headers + post_header["Content-Length"] = str(len(body)) + post_header["Content-Type"] = "application/json" 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", - }, + headers=post_header, ) # Stream output, otherwise we get a timeout error res = self.conn.getresponse() @@ -122,17 +417,37 @@ def post(self, uri, data): except Exception: break return stream - except Exception: - time.sleep(1) + except Exception as e: + self.reset_connection() + attempt += 1 + if attempt > 5: + self.log.error(f"Error LND POST, Abort: {e}") + return None + sleep(1) - def newaddress(self): - res = self.get("/v1/newaddress") - return json.loads(res) + def newaddress(self, max_tries=10): + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.get("/v1/newaddress") + res = json.loads(response) + if "address" in res: + return True, res["address"] + else: + self.log.warning( + f"Couldn't get wallet address from {self.name}:\n {res}\n wait and retry..." + ) + sleep(1) + return False, "" - def walletbalance(self): + def walletbalance(self) -> int: res = self.get("/v1/balance/blockchain") return int(json.loads(res)["confirmed_balance"]) + def channelbalance(self) -> int: + res = self.get("/v1/balance/channels") + return int(json.loads(res)["balance"]) + def uri(self): res = self.get("/v1/getinfo") info = json.loads(res) @@ -145,17 +460,35 @@ def connect(self, target_uri): 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 channel(self, pk, capacity, push_amt, fee_rate, max_tries=2): + b64_pk = self.hex_to_b64(pk) + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post( + "/v1/channels/stream", + data={ + "local_funding_amount": capacity, + "push_sat": push_amt, + "node_pubkey": b64_pk, + "sat_per_vbyte": fee_rate, + }, + ) + try: + res = json.loads(response) + if "result" in res: + res["txid"] = self.b64_to_hex( + res["result"]["chan_pending"]["txid"], reverse=True + ) + res["outpoint"] = ( + f"{res['txid']}:{res['result']['chan_pending']['output_index']}" + ) + return res + self.log.warning(f"Open LND channel error: {res}") + except Exception as e: + self.log.error(f"Error opening LND channel: {e}") + sleep(2) + return None def update(self, txid_hex: str, policy: dict, capacity: int): ln_policy = Policy.from_dict(policy).to_lnd_chanpolicy(capacity) @@ -169,6 +502,28 @@ def update(self, txid_hex: str, policy: dict, capacity: int): ) return json.loads(res) + def createinvoice(self, sats, label, description="new invoice") -> str: + b64_desc = base64.b64encode(description.encode("utf-8")) + response = self.post( + "/v1/invoices", data={"value": sats, "memo": label, "description_hash": b64_desc} + ) + if response: + res = json.loads(response) + return res["payment_request"] + return None + + def payinvoice(self, payment_request) -> str: + response = self.post( + "/v1/channels/transaction-stream", data={"payment_request": payment_request} + ) + if response: + res = json.loads(response) + if "payment_error" in res: + return res["payment_error"] + else: + return res["payment_hash"] + return None + 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 74b0ebdf1..416763ed7 100644 --- a/resources/scenarios/ln_init.py +++ b/resources/scenarios/ln_init.py @@ -42,18 +42,12 @@ def gen(n): 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) + success, address = ln.newaddress() + if success: + ln_addrs.append(address) + self.log.info(f"Got wallet address {address} from {ln.name}") + else: + self.log.info(f"Couldn't get wallet address from {ln.name}") addr_threads = [ threading.Thread(target=get_ln_addr, args=(self, ln)) for ln in self.lns.values() @@ -166,15 +160,18 @@ def connect_ln(self, pair): ) sleep(1) else: - self.log.info( + self.log.error( f"Unexpected response attempting to connect {pair[0].name} -> {pair[1].name}:\n {res}\n ABORTING" ) - break + raise Exception( + f"Unable to connect {pair[0].name} -> {pair[1].name}:\n {res}" + ) p2p_threads = [ threading.Thread(target=connect_ln, args=(self, pair)) for pair in connections ] for thread in p2p_threads: + sleep(0.25) thread.start() all(thread.join() is None for thread in p2p_threads) @@ -188,7 +185,8 @@ def connect_ln(self, pair): # so their channel ids are deterministic ch_by_block = {} for ch in self.channels: - # TODO: if "id" not in ch ... + if "id" not in ch or "block" not in ch["id"]: + raise Exception(f"LN Channel {ch} not found") block = ch["id"]["block"] if block not in ch_by_block: ch_by_block[block] = [ch] @@ -214,24 +212,24 @@ def open_channel(self, ch, fee_rate): f"Sending channel open from {ch['source']} -> {ch['target']} with fee_rate={fee_rate}" ) res = src.channel( - pk=self.hex_to_b64(tgt_pk), + pk=tgt_pk, capacity=ch["capacity"], push_amt=ch["push_amt"], fee_rate=fee_rate, ) - if "result" not in res: + if res and "txid" in res: + ch["txid"] = res["txid"] self.log.info( - "Unexpected channel open response:\n " - + f"From {ch['source']} -> {ch['target']} fee_rate={fee_rate}\n " - + f"{res}" + f"Channel open {ch['source']} -> {ch['target']}\n " + + f"outpoint={res['outpoint']}\n " + + f"expected channel id: {ch['id']}" ) else: - txid = self.b64_to_hex(res["result"]["chan_pending"]["txid"], reverse=True) - ch["txid"] = txid + ch["txid"] = "N/A" self.log.info( - f"Channel open {ch['source']} -> {ch['target']}\n " - + f"outpoint={txid}:{res['result']['chan_pending']['output_index']}\n " - + f"expected channel id: {ch['id']}" + "Unexpected channel open response:\n " + + f"From {ch['source']} -> {ch['target']} fee_rate={fee_rate}\n " + + f"{res}" ) channels = sorted(ch_by_block[target_block], key=lambda ch: ch["id"]["index"]) @@ -244,6 +242,7 @@ def open_channel(self, ch, fee_rate): 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)) + sleep(0.25) t.start() ch_threads.append(t) @@ -260,6 +259,7 @@ def open_channel(self, ch, fee_rate): block_txs = block["tx"] block_height = block["height"] for ch in channels: + assert ch["txid"] != "N/A", f"Channel:{ch} did not receive txid" 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}" @@ -273,14 +273,26 @@ def open_channel(self, ch, fee_rate): 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") + attempts = 0 + actual = 0 + while actual != expected: + actual = len(ln.graph()["edges"]) + if attempts > 10: + break + attempts += 1 + sleep(5) + if actual == expected: + self.log.info(f"LN {ln.name} has graph with all {expected} channels") + else: + self.log.error( + f"LN {ln.name} graph is INCOMPLETE - {actual} of {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: + sleep(0.25) thread.start() all(thread.join() is None for thread in ch_ann_threads) @@ -311,6 +323,7 @@ def update_policy(self, ln, txid_hex, policy, capacity): ch["capacity"], ), ) + sleep(0.25) ts.start() update_threads.append(ts) if "target_policy" in ch: @@ -324,6 +337,7 @@ def update_policy(self, ln, txid_hex, policy, capacity): ch["capacity"], ), ) + sleep(0.25) tt.start() update_threads.append(tt) count = len(update_threads) @@ -337,16 +351,23 @@ 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: + done = False + while not done: actual = ln.graph()["edges"] - assert len(expected) == len(actual) - done = True + self.log.debug(f"LN {ln.name} channel graph edges: {actual}") + if len(actual) > 0: + done = True + assert len(expected) == len(actual), ( + f"Expected edges {len(expected)}, actual edges {len(actual)}\n{actual}" + ) 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 + assert int(actual_ch["capacity"]) == capacity, ( + f"LN {ln.name} graph capacity mismatch:\n actual: {actual_ch['capacity']}\n expected: {capacity}" + ) # Policies were not defined in network.yaml if "source_policy" not in expected_ch or "target_policy" not in expected_ch: @@ -367,12 +388,10 @@ def matching_graph(self, expected, ln): ): continue done = False - break if done: self.log.info(f"LN {ln.name} graph channel policies all match expected source") - break else: - sleep(1) + sleep(5) expected = sorted(self.channels, key=lambda ch: (ch["id"]["block"], ch["id"]["index"])) policy_threads = [ @@ -380,6 +399,7 @@ def matching_graph(self, expected, ln): for ln in self.lns.values() ] for thread in policy_threads: + sleep(0.25) thread.start() all(thread.join() is None for thread in policy_threads) diff --git a/src/warnet/bitcoin.py b/src/warnet/bitcoin.py index dd06c773c..9d0c54f50 100644 --- a/src/warnet/bitcoin.py +++ b/src/warnet/bitcoin.py @@ -6,10 +6,9 @@ from typing import Optional import click -from urllib3.exceptions import MaxRetryError - from test_framework.messages import ser_uint256 from test_framework.p2p import MESSAGEMAP +from urllib3.exceptions import MaxRetryError from .constants import BITCOINCORE_CONTAINER from .k8s import get_default_namespace_or, get_mission, pod_log diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 017c9a749..5e79bea8c 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -65,7 +65,6 @@ class AnnexMember(Enum): # 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 20e431f29..b8c22d932 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -37,7 +37,6 @@ get_mission, get_namespaces_by_type, wait_for_ingress_controller, - wait_for_pod, wait_for_pod_ready, ) from .process import run_command, stream_command @@ -365,11 +364,22 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str network_file = yaml.safe_load(f) needs_ln_init = False + supported_ln_projects = ["lnd", "cln"] 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 + ln_config = node.get("ln", {}) + for key in supported_ln_projects: + if ln_config.get(key, False) and key in node and "channels" in node[key]: + needs_ln_init = True + break + if needs_ln_init: break + default_file_path = directory / DEFAULTS_FILE + with default_file_path.open() as f: + default_file = yaml.safe_load(f) + if any(default_file.get("ln", {}).get(key, False) for key in supported_ln_projects): + needs_ln_init = True + processes = [] for node in network_file["nodes"]: p = Process(target=deploy_single_node, args=(node, directory, debug, namespace)) @@ -388,7 +398,7 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str admin=False, namespace=namespace, ) - wait_for_pod(name, namespace=namespace) + wait_for_pod_ready(name, namespace=namespace) _logs(pod_name=name, follow=True, namespace=namespace) diff --git a/src/warnet/k8s.py b/src/warnet/k8s.py index c12e4de1b..d5da36dc4 100644 --- a/src/warnet/k8s.py +++ b/src/warnet/k8s.py @@ -596,3 +596,58 @@ def download( os.remove(tar_file) return destination_path + + +def read_file_from_container( + pod_name, + source_path: Path, + container_name: str = "", + namespace: Optional[str] = None, + quiet: bool = False, +) -> str: + """Download the file from the `source_path` to the `destination_path`""" + + namespace = get_default_namespace_or(namespace) + + v1 = get_static_client() + + command = ["cat", str(source_path)] + + resp = stream( + v1.connect_get_namespaced_pod_exec, + name=pod_name, + namespace=namespace, + container=container_name, + command=command, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + + result = "" + while resp.is_open(): + resp.update(timeout=5) + if resp.peek_stdout(): + result += resp.read_stdout() + if resp.peek_stderr(): + raise Exception(resp.read_stderr()) + resp.close() + return result + + +def copyfile(pod_name, src_container, source_path, dst_name, dst_container, dst_path): + namespace = get_default_namespace() + file_data = read_file_from_container(pod_name, source_path, src_container, namespace) + if write_file_to_container( + dst_name, + dst_container, + dst_path, + file_data, + namespace=namespace, + quiet=True, + ): + print(f"Copied {source_path} to {dst_path}") + else: + print(f"Failed to copy {source_path} from {pod_name} to {dst_name}:{dst_path}") diff --git a/src/warnet/ln.py b/src/warnet/ln.py index a1f7c1eb2..d3691bd4d 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -31,7 +31,10 @@ def _rpc(pod_name: str, method: str, params: str = "", namespace: Optional[str] 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))}" + ln_client = "lncli" + if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: + ln_client = "lightning-cli" + cmd = f"kubectl -n {namespace} exec {pod_name} -- {ln_client} --network {chain} {method} {' '.join(map(str, params))}" return run_command(cmd) @@ -46,9 +49,13 @@ def pubkey( print(_pubkey(pod)) -def _pubkey(pod: str): - info = _rpc(pod, "getinfo") - return json.loads(info)["identity_pubkey"] +def _pubkey(pod_name: str): + info = _rpc(pod_name, "getinfo") + pod = get_pod(pod_name) + pubkey_key = "identity_pubkey" + if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: + pubkey_key = "id" + return json.loads(info)[pubkey_key] @ln.command() @@ -62,10 +69,14 @@ def host( print(_host(pod)) -def _host(pod): - info = _rpc(pod, "getinfo") - uris = json.loads(info)["uris"] - if uris and len(uris) >= 0: - return uris[0].split("@")[1] +def _host(pod_name: str): + info = _rpc(pod_name, "getinfo") + pod = get_pod(pod_name) + if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: + return json.loads(info)["alias"] else: - return "" + uris = json.loads(info)["uris"] + if uris and len(uris) >= 0: + return uris[0].split("@")[1] + else: + return "" diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index fdb479dbd..dbbbb767a 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -47,8 +47,8 @@ def setup_network(self): def fund_wallets(self): outputs = "" - for lnd in self.lns: - addr = json.loads(self.warnet(f"ln rpc {lnd} newaddress p2wkh"))["address"] + for ln in self.lns: + addr = json.loads(self.warnet(f"ln rpc {ln} newaddress p2wkh"))["address"] outputs += f',"{addr}":10' # trim first comma outputs = outputs[1:]