Skip to content

Commit 8381ebf

Browse files
authored
Merge pull request #485 from willcl-ark/new-ux
2 parents 403fdbc + a6ac73f commit 8381ebf

File tree

12 files changed

+663
-61
lines changed

12 files changed

+663
-61
lines changed

src/warnet/control.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import json
2+
import os
3+
import tempfile
4+
import time
5+
6+
import click
7+
import yaml
8+
from rich import print
9+
from rich.console import Console
10+
from rich.prompt import Confirm, Prompt
11+
from rich.table import Table
12+
13+
from .k8s import (
14+
apply_kubernetes_yaml,
15+
delete_namespace,
16+
get_default_namespace,
17+
get_mission,
18+
get_pods,
19+
)
20+
from .process import run_command, stream_command
21+
22+
console = Console()
23+
24+
25+
def get_active_scenarios():
26+
"""Get list of active scenarios"""
27+
commanders = get_mission("commander")
28+
return [c.metadata.name for c in commanders]
29+
30+
31+
@click.command()
32+
@click.argument("scenario_name", required=False)
33+
def stop(scenario_name):
34+
"""Stop a running scenario or all scenarios"""
35+
active_scenarios = get_active_scenarios()
36+
37+
if not active_scenarios:
38+
console.print("[bold red]No active scenarios found.[/bold red]")
39+
return
40+
41+
if not scenario_name:
42+
table = Table(title="Active Scenarios", show_header=True, header_style="bold magenta")
43+
table.add_column("Number", style="cyan", justify="right")
44+
table.add_column("Scenario Name", style="green")
45+
46+
for idx, name in enumerate(active_scenarios, 1):
47+
table.add_row(str(idx), name)
48+
49+
console.print(table)
50+
51+
choices = [str(i) for i in range(1, len(active_scenarios) + 1)] + ["a", "q"]
52+
choice = Prompt.ask(
53+
"[bold yellow]Enter the number of the scenario to stop, 'a' to stop all, or 'q' to quit[/bold yellow]",
54+
choices=choices,
55+
show_choices=False,
56+
)
57+
58+
if choice == "q":
59+
console.print("[bold blue]Operation cancelled.[/bold blue]")
60+
return
61+
elif choice == "a":
62+
if Confirm.ask("[bold red]Are you sure you want to stop all scenarios?[/bold red]"):
63+
stop_all_scenarios(active_scenarios)
64+
else:
65+
console.print("[bold blue]Operation cancelled.[/bold blue]")
66+
return
67+
68+
scenario_name = active_scenarios[int(choice) - 1]
69+
70+
if scenario_name not in active_scenarios:
71+
console.print(f"[bold red]No active scenario found with name: {scenario_name}[/bold red]")
72+
return
73+
74+
stop_scenario(scenario_name)
75+
76+
77+
def stop_scenario(scenario_name):
78+
"""Stop a single scenario"""
79+
cmd = f"kubectl delete pod {scenario_name}"
80+
if stream_command(cmd):
81+
console.print(f"[bold green]Successfully stopped scenario: {scenario_name}[/bold green]")
82+
else:
83+
console.print(f"[bold red]Failed to stop scenario: {scenario_name}[/bold red]")
84+
85+
86+
def stop_all_scenarios(scenarios):
87+
"""Stop all active scenarios"""
88+
with console.status("[bold yellow]Stopping all scenarios...[/bold yellow]"):
89+
for scenario in scenarios:
90+
stop_scenario(scenario)
91+
console.print("[bold green]All scenarios have been stopped.[/bold green]")
92+
93+
94+
def list_active_scenarios():
95+
"""List all active scenarios"""
96+
commanders = get_mission("commander")
97+
if not commanders:
98+
print("No active scenarios found.")
99+
return
100+
101+
console = Console()
102+
table = Table(title="Active Scenarios", show_header=True, header_style="bold magenta")
103+
table.add_column("Name", style="cyan")
104+
table.add_column("Status", style="green")
105+
106+
for commander in commanders:
107+
table.add_row(commander.metadata.name, commander.status.phase.lower())
108+
109+
console.print(table)
110+
111+
112+
@click.command()
113+
def down():
114+
"""Bring down a running warnet"""
115+
console.print("[bold yellow]Bringing down the warnet...[/bold yellow]")
116+
117+
# Delete warnet-logging namespace
118+
if delete_namespace("warnet-logging"):
119+
console.print("[green]Warnet logging deleted[/green]")
120+
else:
121+
console.print("[red]Warnet logging NOT deleted[/red]")
122+
123+
# Uninstall tanks
124+
tanks = get_mission("tank")
125+
with console.status("[yellow]Uninstalling tanks...[/yellow]"):
126+
for tank in tanks:
127+
cmd = f"helm uninstall {tank.metadata.name}"
128+
if stream_command(cmd):
129+
console.print(f"[green]Uninstalled tank: {tank.metadata.name}[/green]")
130+
else:
131+
console.print(f"[red]Failed to uninstall tank: {tank.metadata.name}[/red]")
132+
133+
# Clean up scenarios and other pods
134+
pods = get_pods()
135+
with console.status("[yellow]Cleaning up remaining pods...[/yellow]"):
136+
for pod in pods.items:
137+
cmd = f"kubectl delete pod {pod.metadata.name}"
138+
if stream_command(cmd):
139+
console.print(f"[green]Deleted pod: {pod.metadata.name}[/green]")
140+
else:
141+
console.print(f"[red]Failed to delete pod: {pod.metadata.name}[/red]")
142+
143+
console.print("[bold green]Warnet has been brought down.[/bold green]")
144+
145+
146+
def get_active_network(namespace):
147+
"""Get the name of the active network (Helm release) in the given namespace"""
148+
cmd = f"helm list --namespace {namespace} --output json"
149+
result = run_command(cmd)
150+
if result:
151+
import json
152+
153+
releases = json.loads(result)
154+
if releases:
155+
# Assuming the first release is the active network
156+
return releases[0]["name"]
157+
return None
158+
159+
160+
@click.command(context_settings={"ignore_unknown_options": True})
161+
@click.argument("scenario_file", type=click.Path(exists=True, file_okay=True, dir_okay=False))
162+
@click.argument("additional_args", nargs=-1, type=click.UNPROCESSED)
163+
def run(scenario_file: str, additional_args: tuple[str]):
164+
"""Run a scenario from a file"""
165+
scenario_path = os.path.abspath(scenario_file)
166+
scenario_name = os.path.splitext(os.path.basename(scenario_path))[0]
167+
168+
with open(scenario_path) as file:
169+
scenario_text = file.read()
170+
171+
name = f"commander-{scenario_name.replace('_', '')}-{int(time.time())}"
172+
namespace = get_default_namespace()
173+
tankpods = get_mission("tank")
174+
tanks = [
175+
{
176+
"tank": tank.metadata.name,
177+
"chain": "regtest",
178+
"rpc_host": tank.status.pod_ip,
179+
"rpc_port": 18443,
180+
"rpc_user": "user",
181+
"rpc_password": "password",
182+
"init_peers": [],
183+
}
184+
for tank in tankpods
185+
]
186+
kubernetes_objects = [
187+
{
188+
"apiVersion": "v1",
189+
"kind": "ConfigMap",
190+
"metadata": {
191+
"name": "warnetjson",
192+
"namespace": namespace,
193+
},
194+
"data": {"warnet.json": json.dumps(tanks)},
195+
},
196+
{
197+
"apiVersion": "v1",
198+
"kind": "ConfigMap",
199+
"metadata": {
200+
"name": "scenariopy",
201+
"namespace": namespace,
202+
},
203+
"data": {"scenario.py": scenario_text},
204+
},
205+
{
206+
"apiVersion": "v1",
207+
"kind": "Pod",
208+
"metadata": {
209+
"name": name,
210+
"namespace": namespace,
211+
"labels": {"mission": "commander"},
212+
},
213+
"spec": {
214+
"restartPolicy": "Never",
215+
"containers": [
216+
{
217+
"name": name,
218+
"image": "bitcoindevproject/warnet-commander:latest",
219+
"args": additional_args,
220+
"volumeMounts": [
221+
{
222+
"name": "warnetjson",
223+
"mountPath": "warnet.json",
224+
"subPath": "warnet.json",
225+
},
226+
{
227+
"name": "scenariopy",
228+
"mountPath": "scenario.py",
229+
"subPath": "scenario.py",
230+
},
231+
],
232+
}
233+
],
234+
"volumes": [
235+
{"name": "warnetjson", "configMap": {"name": "warnetjson"}},
236+
{"name": "scenariopy", "configMap": {"name": "scenariopy"}},
237+
],
238+
},
239+
},
240+
]
241+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as temp_file:
242+
yaml.dump_all(kubernetes_objects, temp_file)
243+
temp_file_path = temp_file.name
244+
245+
if apply_kubernetes_yaml(temp_file_path):
246+
print(f"Successfully started scenario: {scenario_name}")
247+
print(f"Commander pod name: {name}")
248+
else:
249+
print(f"Failed to start scenario: {scenario_name}")
250+
251+
os.unlink(temp_file_path)

src/warnet/deploy.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import tempfile
2+
from pathlib import Path
3+
4+
import click
5+
import yaml
6+
7+
from .k8s import get_default_namespace
8+
from .namespaces import (
9+
BITCOIN_CHART_LOCATION as NAMESPACES_CHART_LOCATION,
10+
)
11+
from .namespaces import (
12+
DEFAULTS_FILE as NAMESPACES_DEFAULTS_FILE,
13+
)
14+
from .namespaces import (
15+
NAMESPACES_FILE,
16+
)
17+
from .network import (
18+
BITCOIN_CHART_LOCATION as NETWORK_CHART_LOCATION,
19+
)
20+
from .network import (
21+
DEFAULTS_FILE as NETWORK_DEFAULTS_FILE,
22+
)
23+
24+
# Import necessary functions and variables from network.py and namespaces.py
25+
from .network import (
26+
NETWORK_FILE,
27+
)
28+
from .process import stream_command
29+
30+
HELM_COMMAND = "helm upgrade --install --create-namespace"
31+
32+
33+
def validate_directory(ctx, param, value):
34+
directory = Path(value)
35+
if not directory.is_dir():
36+
raise click.BadParameter(f"'{value}' is not a valid directory.")
37+
if not (directory / NETWORK_FILE).exists() and not (directory / NAMESPACES_FILE).exists():
38+
raise click.BadParameter(
39+
f"'{value}' does not contain a valid network.yaml or namespaces.yaml file."
40+
)
41+
return directory
42+
43+
44+
@click.command()
45+
@click.argument(
46+
"directory",
47+
type=click.Path(exists=True, file_okay=False, dir_okay=True),
48+
callback=validate_directory,
49+
)
50+
def deploy(directory):
51+
"""Deploy a warnet with topology loaded from <directory>"""
52+
directory = Path(directory)
53+
54+
if (directory / NETWORK_FILE).exists():
55+
deploy_network(directory)
56+
elif (directory / NAMESPACES_FILE).exists():
57+
deploy_namespaces(directory)
58+
else:
59+
click.echo(
60+
"Error: Neither network.yaml nor namespaces.yaml found in the specified directory."
61+
)
62+
63+
64+
def deploy_network(directory: Path):
65+
network_file_path = directory / NETWORK_FILE
66+
defaults_file_path = directory / NETWORK_DEFAULTS_FILE
67+
68+
with network_file_path.open() as f:
69+
network_file = yaml.safe_load(f)
70+
71+
namespace = get_default_namespace()
72+
73+
for node in network_file["nodes"]:
74+
click.echo(f"Deploying node: {node.get('name')}")
75+
try:
76+
temp_override_file_path = ""
77+
node_name = node.get("name")
78+
node_config_override = {k: v for k, v in node.items() if k != "name"}
79+
80+
cmd = f"{HELM_COMMAND} {node_name} {NETWORK_CHART_LOCATION} --namespace {namespace} -f {defaults_file_path}"
81+
82+
if node_config_override:
83+
with tempfile.NamedTemporaryFile(
84+
mode="w", suffix=".yaml", delete=False
85+
) as temp_file:
86+
yaml.dump(node_config_override, temp_file)
87+
temp_override_file_path = Path(temp_file.name)
88+
cmd = f"{cmd} -f {temp_override_file_path}"
89+
90+
if not stream_command(cmd):
91+
click.echo(f"Failed to run Helm command: {cmd}")
92+
return
93+
except Exception as e:
94+
click.echo(f"Error: {e}")
95+
return
96+
finally:
97+
if temp_override_file_path:
98+
Path(temp_override_file_path).unlink()
99+
100+
101+
def deploy_namespaces(directory: Path):
102+
namespaces_file_path = directory / NAMESPACES_FILE
103+
defaults_file_path = directory / NAMESPACES_DEFAULTS_FILE
104+
105+
with namespaces_file_path.open() as f:
106+
namespaces_file = yaml.safe_load(f)
107+
108+
names = [n.get("name") for n in namespaces_file["namespaces"]]
109+
for n in names:
110+
if not n.startswith("warnet-"):
111+
click.echo(
112+
f"Failed to create namespace: {n}. Namespaces must start with a 'warnet-' prefix."
113+
)
114+
return
115+
116+
for namespace in namespaces_file["namespaces"]:
117+
click.echo(f"Deploying namespace: {namespace.get('name')}")
118+
try:
119+
temp_override_file_path = Path()
120+
namespace_name = namespace.get("name")
121+
namespace_config_override = {k: v for k, v in namespace.items() if k != "name"}
122+
123+
cmd = f"{HELM_COMMAND} {namespace_name} {NAMESPACES_CHART_LOCATION} -f {defaults_file_path}"
124+
125+
if namespace_config_override:
126+
with tempfile.NamedTemporaryFile(
127+
mode="w", suffix=".yaml", delete=False
128+
) as temp_file:
129+
yaml.dump(namespace_config_override, temp_file)
130+
temp_override_file_path = Path(temp_file.name)
131+
cmd = f"{cmd} -f {temp_override_file_path}"
132+
133+
if not stream_command(cmd):
134+
click.echo(f"Failed to run Helm command: {cmd}")
135+
return
136+
except Exception as e:
137+
click.echo(f"Error: {e}")
138+
return
139+
finally:
140+
if temp_override_file_path.exists():
141+
temp_override_file_path.unlink()

0 commit comments

Comments
 (0)