Skip to content

Commit 691cff5

Browse files
committed
allow plugins to take namespace and nargs
Also, add a hello-world test
1 parent 3a785b0 commit 691cff5

File tree

8 files changed

+247
-13
lines changed

8 files changed

+247
-13
lines changed

resources/plugins/simln/plugin.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
# When we want to select pods based on their role in Warnet, we use "mission" tags. The "mission"
1212
# tag for "lightning" nodes is stored in LIGHTNING_MISSION.
13-
from warnet.constants import LIGHTNING_MISSION
13+
from warnet.constants import LIGHTNING_MISSION, HookValue
1414
from warnet.k8s import (
1515
download,
1616
get_default_namespace,
@@ -56,13 +56,24 @@ def simln(ctx):
5656
ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir)
5757

5858

59+
# Each Warnet plugin must have an entrypoint function which takes a network_file_path and a
60+
# hook_value. Possible hook values can be found in the HookValue enum. It also takes a namespace
61+
# value and a variable number of arguments which is used by, for example, preNode and postNode to
62+
# pass along node names.
5963
@simln.command()
6064
@click.argument("network_file_path", type=str)
6165
@click.argument("hook_value", type=str)
66+
@click.argument("namespace", type=str)
67+
@click.argument("nargs", nargs=-1)
6268
@click.pass_context
63-
def entrypoint(ctx, network_file_path: str, hook_value: str):
69+
def entrypoint(ctx, network_file_path: str, hook_value: str, namespace: str, nargs):
6470
"""Plugin entrypoint"""
71+
assert hook_value in {
72+
item.value for item in HookValue
73+
}, f"{hook_value} is not a valid HookValue"
74+
6575
network_file_path = Path(network_file_path)
76+
6677
with network_file_path.open() as f:
6778
network_file = yaml.safe_load(f) or {}
6879
if not isinstance(network_file, dict):
@@ -76,11 +87,11 @@ def entrypoint(ctx, network_file_path: str, hook_value: str):
7687
if not plugin_data:
7788
raise PluginError(f"Could not find {plugin_name} in {network_file_path}")
7889

79-
_entrypoint(ctx, plugin_data)
90+
_entrypoint(ctx, plugin_data, HookValue(hook_value), namespace, nargs)
8091

8192

82-
def _entrypoint(ctx, plugin_data: dict):
83-
""" "Called by entrypoint"""
93+
def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, nargs):
94+
"""Called by entrypoint"""
8495
# write your plugin startup commands here
8596
activity = plugin_data.get("activity")
8697
activity = json.loads(activity)

src/warnet/deploy.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def _deploy(directory, debug, namespace, to_all_users):
8585
return
8686

8787
if (directory / NETWORK_FILE).exists():
88-
run_plugins(directory, HookValue.PRE_DEPLOY)
88+
run_plugins(directory, HookValue.PRE_DEPLOY, namespace)
8989

9090
processes = []
9191
# Deploy logging CRD first to avoid synchronisation issues
@@ -95,7 +95,7 @@ def _deploy(directory, debug, namespace, to_all_users):
9595
logging_process.start()
9696
processes.append(logging_process)
9797

98-
run_plugins(directory, HookValue.PRE_NETWORK)
98+
run_plugins(directory, HookValue.PRE_NETWORK, namespace)
9999

100100
network_process = Process(target=deploy_network, args=(directory, debug, namespace))
101101
network_process.start()
@@ -111,7 +111,7 @@ def _deploy(directory, debug, namespace, to_all_users):
111111
# Wait for the network process to complete
112112
network_process.join()
113113

114-
run_plugins(directory, HookValue.POST_NETWORK)
114+
run_plugins(directory, HookValue.POST_NETWORK, namespace)
115115

116116
# Start the fork observer process immediately after network process completes
117117
fork_observer_process = Process(target=deploy_fork_observer, args=(directory, debug))
@@ -122,7 +122,7 @@ def _deploy(directory, debug, namespace, to_all_users):
122122
for p in processes:
123123
p.join()
124124

125-
run_plugins(directory, HookValue.POST_DEPLOY)
125+
run_plugins(directory, HookValue.POST_DEPLOY, namespace)
126126

127127
elif (directory / NAMESPACES_FILE).exists():
128128
deploy_namespaces(directory)
@@ -132,7 +132,7 @@ def _deploy(directory, debug, namespace, to_all_users):
132132
)
133133

134134

135-
def run_plugins(directory, hook_value: HookValue):
135+
def run_plugins(directory, hook_value: HookValue, namespace, *args):
136136
"""Run the plugin commands within a given hook value"""
137137

138138
network_file_path = directory / NETWORK_FILE
@@ -154,7 +154,7 @@ def run_plugins(directory, hook_value: HookValue):
154154
except Exception as err:
155155
raise SyntaxError("Each plugin must have an 'entrypoint'") from err
156156

157-
cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value}"
157+
cmd = f"{network_file_path.parent / entrypoint_path / Path('plugin.py')} entrypoint {network_file_path} {hook_value.value} {namespace} {' '.join(map(str, args))}"
158158
process = Process(target=run_command, args=(cmd,))
159159
processes.append(process)
160160

@@ -385,13 +385,13 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str):
385385
temp_override_file_path = Path(temp_file.name)
386386
cmd = f"{cmd} -f {temp_override_file_path}"
387387

388-
run_plugins(directory, HookValue.PRE_NODE)
388+
run_plugins(directory, HookValue.PRE_NODE, namespace, node_name)
389389

390390
if not stream_command(cmd):
391391
click.echo(f"Failed to run Helm command: {cmd}")
392392
return
393393

394-
run_plugins(directory, HookValue.POST_NODE)
394+
run_plugins(directory, HookValue.POST_NODE, namespace, node_name)
395395

396396
except Exception as e:
397397
click.echo(f"Error: {e}")

test/data/ln/network.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,31 @@ nodes:
5454
lnd: true
5555

5656
plugins:
57+
preDeploy:
58+
hello:
59+
entrypoint: "../plugins/hello"
60+
podName: "hello-pre-deploy"
61+
helloTo: "preDeploy!"
5762
postDeploy:
5863
simln:
5964
entrypoint: "../../../resources/plugins/simln"
6065
activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]'
66+
preNode:
67+
hello:
68+
entrypoint: "../plugins/hello"
69+
helloTo: "preNode!"
70+
postNode:
71+
hello:
72+
entrypoint: "../plugins/hello"
73+
helloTo: "preNode!"
74+
preNetwork:
75+
hello:
76+
entrypoint: "../plugins/hello"
77+
helloTo: "preNetwork!"
78+
podName: "hello-pre-network"
79+
postNetwork:
80+
hello:
81+
entrypoint: "../plugins/hello"
82+
helloTo: "postNetwork!"
83+
podName: "hello-post-network"
6184

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: v2
2+
name: hello-chart
3+
description: A Helm chart for a hello Pod
4+
version: 0.1.0
5+
appVersion: "1.0"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
apiVersion: v1
2+
kind: Pod
3+
metadata:
4+
name: {{ .Values.podName }}
5+
labels:
6+
app: {{ .Chart.Name }}
7+
spec:
8+
restartPolicy: Never
9+
containers:
10+
- name: {{ .Values.podName }}-container
11+
image: alpine:latest
12+
command: ["sh", "-c"]
13+
args:
14+
- echo "Hello {{ .Values.helloTo }}";
15+
resources: {}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
podName: hello-pod
2+
helloTo: "world"

test/data/plugins/hello/plugin.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#!/usr/bin/env python3
2+
import logging
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
import click
7+
import yaml
8+
from kubernetes.stream import stream
9+
10+
from warnet.constants import HookValue
11+
from warnet.k8s import (
12+
get_default_namespace,
13+
get_static_client,
14+
)
15+
from warnet.process import run_command
16+
17+
MISSION = "hello"
18+
PRIMARY_CONTAINER = MISSION
19+
20+
PLUGIN_DIR_TAG = "plugin_dir"
21+
22+
23+
class PluginError(Exception):
24+
pass
25+
26+
27+
log = logging.getLogger(MISSION)
28+
if not log.hasHandlers():
29+
console_handler = logging.StreamHandler()
30+
console_handler.setLevel(logging.DEBUG)
31+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
32+
console_handler.setFormatter(formatter)
33+
log.addHandler(console_handler)
34+
log.setLevel(logging.DEBUG)
35+
log.propagate = True
36+
37+
38+
@click.group()
39+
@click.pass_context
40+
def hello(ctx):
41+
"""Commands for the Hello plugin"""
42+
ctx.ensure_object(dict)
43+
plugin_dir = Path(__file__).resolve().parent
44+
ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir)
45+
46+
47+
@hello.command()
48+
@click.argument("network_file_path", type=str)
49+
@click.argument("hook_value", type=str)
50+
@click.argument("namespace", type=str)
51+
@click.argument("nargs", nargs=-1)
52+
@click.pass_context
53+
def entrypoint(ctx, network_file_path: str, hook_value: str, namespace: str, nargs):
54+
"""Plugin entrypoint"""
55+
assert hook_value in {
56+
item.value for item in HookValue
57+
}, f"{hook_value} is not a valid HookValue"
58+
59+
network_file_path = Path(network_file_path)
60+
61+
with network_file_path.open() as f:
62+
network_file = yaml.safe_load(f) or {}
63+
if not isinstance(network_file, dict):
64+
raise ValueError(f"Invalid network file structure: {network_file_path}")
65+
66+
plugins_section = network_file.get("plugins", {})
67+
hook_section = plugins_section.get(hook_value, {})
68+
69+
plugin_name = Path(__file__).resolve().parent.stem
70+
plugin_data = hook_section.get(plugin_name)
71+
if not plugin_data:
72+
raise PluginError(f"Could not find {plugin_name} in {network_file_path}")
73+
74+
_entrypoint(ctx, plugin_data, HookValue(hook_value), namespace, nargs)
75+
76+
77+
def _entrypoint(ctx, plugin_data: dict, hook_value: HookValue, namespace: str, nargs):
78+
"""Called by entrypoint"""
79+
match hook_value:
80+
case (
81+
HookValue.PRE_NETWORK
82+
| HookValue.POST_NETWORK
83+
| HookValue.PRE_DEPLOY
84+
| HookValue.POST_DEPLOY
85+
):
86+
data = get_data(plugin_data)
87+
if data:
88+
_launch_pod(ctx, install_name=hook_value.value.lower() + "-hello", **data)
89+
else:
90+
_launch_pod(ctx, install_name=hook_value.value.lower() + "-hello")
91+
case HookValue.PRE_NODE:
92+
name = nargs[0] + "-pre-hello-pod"
93+
_launch_pod(ctx, install_name=hook_value.value.lower() + "-" + name, podName=name)
94+
case HookValue.POST_NODE:
95+
name = nargs[0] + "-post-hello-pod"
96+
_launch_pod(ctx, install_name=hook_value.value.lower() + "-" + name, podName=name)
97+
98+
99+
def get_data(plugin_data: dict) -> Optional[dict]:
100+
data = {key: plugin_data.get(key) for key in ("podName", "helloTo") if plugin_data.get(key)}
101+
return data or None
102+
103+
104+
def _launch_pod(
105+
ctx, install_name: str = "hello", podName: str = "hello-pod", helloTo: str = "World!"
106+
):
107+
command = f"helm upgrade --install {install_name} {ctx.obj[PLUGIN_DIR_TAG]}/charts/hello --set podName={podName} --set helloTo={helloTo}"
108+
log.info(command)
109+
log.info(run_command(command))
110+
111+
112+
def _sh(pod, method: str, params: tuple[str, ...]) -> str:
113+
namespace = get_default_namespace()
114+
115+
sclient = get_static_client()
116+
if params:
117+
cmd = [method]
118+
cmd.extend(params)
119+
else:
120+
cmd = [method]
121+
try:
122+
resp = stream(
123+
sclient.connect_get_namespaced_pod_exec,
124+
pod,
125+
namespace,
126+
container=PRIMARY_CONTAINER,
127+
command=cmd,
128+
stderr=True,
129+
stdin=False,
130+
stdout=True,
131+
tty=False,
132+
_preload_content=False,
133+
)
134+
stdout = ""
135+
stderr = ""
136+
while resp.is_open():
137+
resp.update(timeout=1)
138+
if resp.peek_stdout():
139+
stdout_chunk = resp.read_stdout()
140+
stdout += stdout_chunk
141+
if resp.peek_stderr():
142+
stderr_chunk = resp.read_stderr()
143+
stderr += stderr_chunk
144+
return stdout + stderr
145+
except Exception as err:
146+
print(f"Could not execute stream: {err}")
147+
148+
149+
@hello.command(context_settings={"ignore_unknown_options": True})
150+
@click.argument("pod", type=str)
151+
@click.argument("method", type=str)
152+
@click.argument("params", type=str, nargs=-1) # this will capture all remaining arguments
153+
def sh(pod: str, method: str, params: tuple[str, ...]):
154+
"""Run shell commands in a pod"""
155+
print(_sh(pod, method, params))
156+
157+
158+
if __name__ == "__main__":
159+
hello()

test/simln_test.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def run_test(self):
2525
self.init_directory()
2626
self.deploy_with_plugin()
2727
self.copy_results()
28+
self.assert_hello_plugin()
2829
finally:
2930
self.cleanup()
3031

@@ -83,6 +84,24 @@ def found_results_locally(self) -> bool:
8384
self.log.info(f"Did not find downloaded results in directory: {directory}.")
8485
return False
8586

87+
def assert_hello_plugin(self):
88+
self.log.info("Waiting for the 'hello' plugin pods.")
89+
wait_for_pod("hello-pre-deploy") # We don't use post-deploy (simln covers that)
90+
wait_for_pod("hello-pre-network")
91+
wait_for_pod("hello-post-network")
92+
wait_for_pod("tank-0000-post-hello-pod")
93+
wait_for_pod("tank-0000-pre-hello-pod")
94+
wait_for_pod("tank-0001-post-hello-pod")
95+
wait_for_pod("tank-0001-pre-hello-pod")
96+
wait_for_pod("tank-0002-post-hello-pod")
97+
wait_for_pod("tank-0002-pre-hello-pod")
98+
wait_for_pod("tank-0003-post-hello-pod")
99+
wait_for_pod("tank-0003-pre-hello-pod")
100+
wait_for_pod("tank-0004-post-hello-pod")
101+
wait_for_pod("tank-0004-pre-hello-pod")
102+
wait_for_pod("tank-0005-post-hello-pod")
103+
wait_for_pod("tank-0005-pre-hello-pod")
104+
86105

87106
if __name__ == "__main__":
88107
test = SimLNTest()

0 commit comments

Comments
 (0)