nodepick-cli 0.1.2__tar.gz → 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodepick_cli-0.1.4/CHANGELOG.md +27 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/PKG-INFO +11 -2
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/README.md +10 -1
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/pyproject.toml +2 -2
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/__init__.py +1 -1
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/ai.py +109 -8
- nodepick_cli-0.1.4/src/nodepick_cli/commands/compute.py +117 -0
- nodepick_cli-0.1.4/src/nodepick_cli/commands/network.py +126 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/node.py +125 -10
- nodepick_cli-0.1.4/src/nodepick_cli/commands/regions.py +83 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/exceptions.py +6 -2
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/main.py +15 -0
- nodepick_cli-0.1.4/test_cli.py +786 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/uv.lock +4 -4
- nodepick_cli-0.1.2/CHANGELOG.md +0 -17
- nodepick_cli-0.1.2/test_cli.py +0 -226
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/.gitignore +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/install.sh +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/auth/__init__.py +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/auth/login.py +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/__init__.py +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/ssh.py +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/__init__.py +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/config.py +0 -0
- {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/formatters.py +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CHANGELOG.md
|
|
2
|
+
|
|
3
|
+
## [0.1.4] - 2026-10-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Added short option `-s` alias to `--storage` on `np node create`.
|
|
7
|
+
- Added `--region` (`-r`) option to `np node create` to specify the deployment region.
|
|
8
|
+
- Added `--host` option to `np node create` to deploy directly onto a specified compute Host ID.
|
|
9
|
+
- Added region validation against supported active regions for `np node create` and `np network create`.
|
|
10
|
+
- Added `np regions` command to list available compute regions.
|
|
11
|
+
- Added `np compute` top-level command for compute discovery with real-time pricing and consolidated RAM/Disk/CPU specs.
|
|
12
|
+
- Added `np --version` (`-v`) flag.
|
|
13
|
+
- Added `np ai mcp configure <agent> [nodes...]` command to configure MCP servers on AI agents (Antigravity, OpenCode).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Changed authentication command from `np auth save` to `np auth configure`.
|
|
17
|
+
- Replaced `np find` and `np node find` with top-level `np compute`.
|
|
18
|
+
- Removed NAT configuration options from `np network` commands (outbound NAT enabled upstream by default).
|
|
19
|
+
- Updated `--region` help message on `np node create` and `np network create` to "Target region or datacenter (e.g. us-west-1, fmt1)".
|
|
20
|
+
- Cleaned option type formatting in `np node create` help output (removed redundant `[x>=...]` range tags).
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-07-31
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Created `np` CLI tool in `cli/` using Typer and Rich.
|
|
26
|
+
- Added commands: `list`, `create`, `get`, `delete`, `shutdown`, `reboot`.
|
|
27
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nodepick-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Official Python CLI for nodepick.ai
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: httpx>=0.24.0
|
|
@@ -65,8 +65,17 @@ Check the official [`Documentation`](https://docs.nodepick.ai/cli-reference/over
|
|
|
65
65
|
### Basic Usage
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
|
-
#
|
|
68
|
+
# Check version
|
|
69
69
|
np --version
|
|
70
|
+
|
|
71
|
+
# List available regions
|
|
72
|
+
np regions
|
|
73
|
+
|
|
74
|
+
# Deploy a compute node in a specific region
|
|
75
|
+
np node create --name dev-node --region us-west-1
|
|
76
|
+
|
|
77
|
+
# List compute nodes
|
|
78
|
+
np node list
|
|
70
79
|
```
|
|
71
80
|
|
|
72
81
|
### Authentication
|
|
@@ -53,8 +53,17 @@ Check the official [`Documentation`](https://docs.nodepick.ai/cli-reference/over
|
|
|
53
53
|
### Basic Usage
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
#
|
|
56
|
+
# Check version
|
|
57
57
|
np --version
|
|
58
|
+
|
|
59
|
+
# List available regions
|
|
60
|
+
np regions
|
|
61
|
+
|
|
62
|
+
# Deploy a compute node in a specific region
|
|
63
|
+
np node create --name dev-node --region us-west-1
|
|
64
|
+
|
|
65
|
+
# List compute nodes
|
|
66
|
+
np node list
|
|
58
67
|
```
|
|
59
68
|
|
|
60
69
|
### Authentication
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nodepick-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "Official Python CLI for nodepick.ai"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,7 +25,7 @@ np = "nodepick_cli.main:app"
|
|
|
25
25
|
packages = ["src/nodepick_cli"]
|
|
26
26
|
|
|
27
27
|
[tool.uv.sources]
|
|
28
|
-
nodepick = { path = "../sdk/python" }
|
|
28
|
+
nodepick = { path = "../sdk/python", editable = true }
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
|
|
@@ -58,6 +58,57 @@ def configure_antigravity_mcp(servers: Dict[str, Any]) -> List[Path]:
|
|
|
58
58
|
return updated_files
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _delete_json_mcp_servers(file_path: Path, server_names: Optional[List[str]] = None) -> List[str]:
|
|
62
|
+
"""Safely remove MCP servers from a JSON configuration file."""
|
|
63
|
+
if not file_path.exists():
|
|
64
|
+
return []
|
|
65
|
+
try:
|
|
66
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
67
|
+
content = f.read().strip()
|
|
68
|
+
if not content:
|
|
69
|
+
return []
|
|
70
|
+
data = json.loads(content)
|
|
71
|
+
except Exception:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
if not isinstance(data, dict) or "mcpServers" not in data or not isinstance(data["mcpServers"], dict):
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
removed = []
|
|
78
|
+
if server_names:
|
|
79
|
+
for name in server_names:
|
|
80
|
+
if name in data["mcpServers"]:
|
|
81
|
+
del data["mcpServers"][name]
|
|
82
|
+
removed.append(name)
|
|
83
|
+
else:
|
|
84
|
+
removed = list(data["mcpServers"].keys())
|
|
85
|
+
data["mcpServers"] = {}
|
|
86
|
+
|
|
87
|
+
if removed:
|
|
88
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
89
|
+
json.dump(data, f, indent=2)
|
|
90
|
+
|
|
91
|
+
return removed
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def delete_antigravity_mcp(server_names: Optional[List[str]] = None) -> List[str]:
|
|
95
|
+
"""Remove MCP servers for Google Antigravity across its standard configuration locations."""
|
|
96
|
+
home = Path.home()
|
|
97
|
+
target_files = [
|
|
98
|
+
home / ".gemini" / "config" / "mcp_config.json",
|
|
99
|
+
home / ".gemini" / "settings.json",
|
|
100
|
+
home / ".gemini" / "antigravity-cli" / "settings.json",
|
|
101
|
+
]
|
|
102
|
+
all_removed = set()
|
|
103
|
+
for path in target_files:
|
|
104
|
+
try:
|
|
105
|
+
removed = _delete_json_mcp_servers(path, server_names)
|
|
106
|
+
all_removed.update(removed)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
console.print(f"[dim yellow]Warning: Failed modifying {path}: {e}[/dim yellow]")
|
|
109
|
+
return list(all_removed)
|
|
110
|
+
|
|
111
|
+
|
|
61
112
|
@mcp_app.command("configure")
|
|
62
113
|
def mcp_configure(
|
|
63
114
|
agent: str = typer.Argument(
|
|
@@ -67,8 +118,19 @@ def mcp_configure(
|
|
|
67
118
|
nodes: Optional[List[str]] = typer.Argument(
|
|
68
119
|
None, help="Node IDs or display names to configure MCP for (if omitted, configures all nodes)"
|
|
69
120
|
),
|
|
121
|
+
insecure: bool = typer.Option(
|
|
122
|
+
False,
|
|
123
|
+
"--insecure",
|
|
124
|
+
help="Disable HTTPS certificate verification for MCP server connections.",
|
|
125
|
+
),
|
|
126
|
+
delete: bool = typer.Option(
|
|
127
|
+
False,
|
|
128
|
+
"--delete",
|
|
129
|
+
"-d",
|
|
130
|
+
help="Delete the MCP server configuration for the specified node(s) or agent.",
|
|
131
|
+
),
|
|
70
132
|
):
|
|
71
|
-
"""Configure MCP servers for an AI agent (e.g. antigravity) to connect to compute nodes."""
|
|
133
|
+
"""Configure or delete MCP servers for an AI agent (e.g. antigravity) to connect to compute nodes."""
|
|
72
134
|
agent_clean = agent.strip().lower()
|
|
73
135
|
if agent_clean not in ("antigravity", "agy", "gemini"):
|
|
74
136
|
console.print(
|
|
@@ -78,6 +140,42 @@ def mcp_configure(
|
|
|
78
140
|
)
|
|
79
141
|
raise typer.Exit(1)
|
|
80
142
|
|
|
143
|
+
if delete:
|
|
144
|
+
try:
|
|
145
|
+
names_to_delete = list(nodes) if nodes else None
|
|
146
|
+
if names_to_delete:
|
|
147
|
+
try:
|
|
148
|
+
client = get_client()
|
|
149
|
+
for n in list(names_to_delete):
|
|
150
|
+
try:
|
|
151
|
+
details = client.node_get_details(n)
|
|
152
|
+
for key in ("display_name", "displayName", "name", "vm_uuid", "id"):
|
|
153
|
+
val = details.get(key)
|
|
154
|
+
if val and val not in names_to_delete:
|
|
155
|
+
names_to_delete.append(val)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
if agent_clean in ("antigravity", "agy", "gemini"):
|
|
162
|
+
removed = delete_antigravity_mcp(names_to_delete)
|
|
163
|
+
if removed:
|
|
164
|
+
for r in sorted(removed):
|
|
165
|
+
console.print(f"[bold green]✔[/bold green] Removed MCP server configuration '[bold]{r}[/bold]'")
|
|
166
|
+
console.print(
|
|
167
|
+
f"[bold green]Successfully removed {len(removed)} MCP server configuration(s) for {agent_clean}.[/bold green]"
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
if nodes:
|
|
171
|
+
console.print(f"[yellow]No matching MCP server configurations found to delete for: {', '.join(nodes)}[/yellow]")
|
|
172
|
+
else:
|
|
173
|
+
console.print(f"[yellow]No MCP server configurations found to delete for {agent_clean}.[/yellow]")
|
|
174
|
+
return
|
|
175
|
+
except Exception as e:
|
|
176
|
+
handle_error(e, "Error deleting MCP configuration")
|
|
177
|
+
return
|
|
178
|
+
|
|
81
179
|
client = get_client()
|
|
82
180
|
|
|
83
181
|
try:
|
|
@@ -131,15 +229,18 @@ def mcp_configure(
|
|
|
131
229
|
"type": "http",
|
|
132
230
|
"transport": "http",
|
|
133
231
|
"protocol": "streamable_http",
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"verify": False,
|
|
138
|
-
"tls": {
|
|
232
|
+
}
|
|
233
|
+
if insecure:
|
|
234
|
+
server_config.update({
|
|
139
235
|
"insecure": True,
|
|
140
236
|
"insecureSkipVerify": True,
|
|
141
|
-
|
|
142
|
-
|
|
237
|
+
"rejectUnauthorized": False,
|
|
238
|
+
"verify": False,
|
|
239
|
+
"tls": {
|
|
240
|
+
"insecure": True,
|
|
241
|
+
"insecureSkipVerify": True,
|
|
242
|
+
},
|
|
243
|
+
})
|
|
143
244
|
if mcp_api_key:
|
|
144
245
|
server_config["headers"] = {
|
|
145
246
|
"Authorization": f"Bearer {mcp_api_key}",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table, Column
|
|
5
|
+
from .node import get_client
|
|
6
|
+
from ..core.exceptions import handle_error
|
|
7
|
+
from ..core.formatters import OutputFormat, set_output_format, print_output
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _render_compute_hosts_table(hosts):
|
|
13
|
+
if not hosts:
|
|
14
|
+
console.print("[yellow]No compute hosts found matching criteria.[/yellow]")
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
table = Table(
|
|
18
|
+
Column("Host ID", no_wrap=True),
|
|
19
|
+
"Status",
|
|
20
|
+
"Region",
|
|
21
|
+
"Datacenter",
|
|
22
|
+
"RAM/Disk/CPU",
|
|
23
|
+
"Price/hr ($)",
|
|
24
|
+
"Price/mo ($)",
|
|
25
|
+
)
|
|
26
|
+
for host in hosts:
|
|
27
|
+
# Show requested specs (RAM, CPU, and Disk) for which pricing was evaluated,
|
|
28
|
+
# rather than the compute host's total reported capacity.
|
|
29
|
+
req_cpu = host.get("requested_cpu")
|
|
30
|
+
if req_cpu is None:
|
|
31
|
+
cpu_info = host.get("cpu", {})
|
|
32
|
+
req_cpu = cpu_info.get("cores") if isinstance(cpu_info, dict) else cpu_info
|
|
33
|
+
|
|
34
|
+
req_mem = host.get("requested_memory_gb")
|
|
35
|
+
if req_mem is None:
|
|
36
|
+
req_mem = host.get("memory_gb")
|
|
37
|
+
elif isinstance(req_mem, (int, float)) and req_mem == int(req_mem):
|
|
38
|
+
req_mem = int(req_mem)
|
|
39
|
+
|
|
40
|
+
req_disk = host.get("requested_storage_gb")
|
|
41
|
+
if req_disk is None:
|
|
42
|
+
req_disk = host.get("storage_gb")
|
|
43
|
+
|
|
44
|
+
def _fmt(val):
|
|
45
|
+
if val is None:
|
|
46
|
+
return "?"
|
|
47
|
+
if isinstance(val, (int, float)):
|
|
48
|
+
return str(int(val)) if val == int(val) else str(val)
|
|
49
|
+
return str(val)
|
|
50
|
+
|
|
51
|
+
specs_str = f"{_fmt(req_mem)}/{_fmt(req_disk)}/{_fmt(req_cpu)}"
|
|
52
|
+
|
|
53
|
+
pricing = host.get("pricing") if isinstance(host.get("pricing"), dict) else {}
|
|
54
|
+
hourly = pricing.get("hourly") or pricing.get("hour") or host.get("estimated_hourly_cost_usd")
|
|
55
|
+
monthly = pricing.get("monthly") or host.get("estimated_monthly_cost_usd")
|
|
56
|
+
|
|
57
|
+
if hourly is not None:
|
|
58
|
+
try:
|
|
59
|
+
hourly_str = f"${float(hourly):.4f}"
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
hourly_str = f"${hourly}"
|
|
62
|
+
else:
|
|
63
|
+
hourly_str = "N/A"
|
|
64
|
+
|
|
65
|
+
if monthly is not None:
|
|
66
|
+
try:
|
|
67
|
+
monthly_str = f"${float(monthly):.2f}"
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
monthly_str = f"${monthly}"
|
|
70
|
+
else:
|
|
71
|
+
monthly_str = "N/A"
|
|
72
|
+
|
|
73
|
+
status_val = str(host.get("status") or "active")
|
|
74
|
+
|
|
75
|
+
table.add_row(
|
|
76
|
+
str(host.get("id") or "N/A"),
|
|
77
|
+
status_val,
|
|
78
|
+
str(host.get("region") or "N/A"),
|
|
79
|
+
str(host.get("datacenter") or "N/A"),
|
|
80
|
+
specs_str,
|
|
81
|
+
hourly_str,
|
|
82
|
+
monthly_str,
|
|
83
|
+
)
|
|
84
|
+
console.print(table)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compute_command(
|
|
88
|
+
cpu: Optional[int] = typer.Option(None, "--cpu", "-c", help="Minimum CPU cores"),
|
|
89
|
+
memory_gb: Optional[float] = typer.Option(None, "--memory", "-m", help="Minimum RAM in GB"),
|
|
90
|
+
storage_gb: Optional[int] = typer.Option(None, "--storage", "-s", help="Minimum storage in GB"),
|
|
91
|
+
max_price: Optional[float] = typer.Option(None, "--max-price", "-p", help="Maximum monthly price in USD"),
|
|
92
|
+
region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by target region"),
|
|
93
|
+
datacenter: Optional[str] = typer.Option(None, "--datacenter", help="Filter by datacenter facility code"),
|
|
94
|
+
status: Optional[str] = typer.Option(None, "--status", help="Filter by host status (active, reservable, all)"),
|
|
95
|
+
format: OutputFormat = typer.Option(
|
|
96
|
+
OutputFormat.TABLE,
|
|
97
|
+
"--format", "-f",
|
|
98
|
+
help="Output format (table or json).",
|
|
99
|
+
case_sensitive=False,
|
|
100
|
+
),
|
|
101
|
+
):
|
|
102
|
+
"""Find and discover available compute hosts matching specific hardware and pricing criteria."""
|
|
103
|
+
set_output_format(format)
|
|
104
|
+
client = get_client()
|
|
105
|
+
try:
|
|
106
|
+
hosts = client.find_compute(
|
|
107
|
+
min_cpu=cpu,
|
|
108
|
+
min_memory_gb=memory_gb,
|
|
109
|
+
min_storage_gb=storage_gb,
|
|
110
|
+
max_price=max_price,
|
|
111
|
+
region=region,
|
|
112
|
+
datacenter=datacenter,
|
|
113
|
+
status=status,
|
|
114
|
+
)
|
|
115
|
+
print_output(hosts, table_render_func=_render_compute_hosts_table)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
handle_error(e, "Error finding compute hosts")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from ..commands.regions import validate_region
|
|
6
|
+
from ..commands.node import get_client
|
|
7
|
+
from ..core.exceptions import handle_error
|
|
8
|
+
from ..core.formatters import OutputFormat, set_output_format, print_output
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(name="network", help="Manage VPC Networks.")
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _render_networks_table(networks):
|
|
15
|
+
if not networks:
|
|
16
|
+
console.print("[yellow]No VPC networks found.[/yellow]")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
table = Table("ID", "Name", "Subnet", "Region")
|
|
20
|
+
for net in networks:
|
|
21
|
+
table.add_row(
|
|
22
|
+
str(net.get("id") or "N/A"),
|
|
23
|
+
str(net.get("name") or "N/A"),
|
|
24
|
+
str(net.get("subnet") or "N/A"),
|
|
25
|
+
str(net.get("region") or "-"),
|
|
26
|
+
)
|
|
27
|
+
console.print(table)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _render_network_details_table(network):
|
|
31
|
+
if not network:
|
|
32
|
+
console.print("[yellow]No VPC network details found.[/yellow]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
table = Table("Field", "Value")
|
|
36
|
+
fields = [
|
|
37
|
+
("ID", network.get("id")),
|
|
38
|
+
("Name", network.get("name")),
|
|
39
|
+
("Description", network.get("description")),
|
|
40
|
+
("Subnet", network.get("subnet")),
|
|
41
|
+
("Gateway", network.get("gateway")),
|
|
42
|
+
("Region", network.get("region")),
|
|
43
|
+
("VNI", network.get("vni")),
|
|
44
|
+
("Outbound NAT", "Enabled" if network.get("enableOutboundNat", True) else "Disabled"),
|
|
45
|
+
("Created At", network.get("createdAt") or network.get("created_at")),
|
|
46
|
+
]
|
|
47
|
+
for field, value in fields:
|
|
48
|
+
if value is not None:
|
|
49
|
+
table.add_row(field, str(value))
|
|
50
|
+
console.print(table)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("list")
|
|
54
|
+
def network_list(
|
|
55
|
+
format: OutputFormat = typer.Option(
|
|
56
|
+
OutputFormat.TABLE,
|
|
57
|
+
"--format", "-f",
|
|
58
|
+
help="Output format (table or json).",
|
|
59
|
+
case_sensitive=False,
|
|
60
|
+
),
|
|
61
|
+
):
|
|
62
|
+
"""List organization VPC networks."""
|
|
63
|
+
set_output_format(format)
|
|
64
|
+
client = get_client()
|
|
65
|
+
try:
|
|
66
|
+
networks = client.vpc_list()
|
|
67
|
+
print_output(networks, table_render_func=_render_networks_table)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
handle_error(e, "Error listing VPC networks")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("create")
|
|
73
|
+
def network_create(
|
|
74
|
+
name: str = typer.Argument(..., help="Name for the VPC network (alphanumeric, dashes, and underscores)"),
|
|
75
|
+
region: str = typer.Option(..., "--region", "-r", help="Target region or datacenter (e.g. us-west-1, fmt1)"),
|
|
76
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="Optional description"),
|
|
77
|
+
):
|
|
78
|
+
"""Create a new VPC network."""
|
|
79
|
+
client = get_client()
|
|
80
|
+
if region:
|
|
81
|
+
validate_region(client, region)
|
|
82
|
+
try:
|
|
83
|
+
console.print(f"[cyan]Creating VPC network '{name}'...[/cyan]")
|
|
84
|
+
network = client.vpc_create(
|
|
85
|
+
name=name,
|
|
86
|
+
region=region,
|
|
87
|
+
description=description,
|
|
88
|
+
)
|
|
89
|
+
vpc_id = network.get("id")
|
|
90
|
+
console.print(f"[bold green]VPC network created successfully![/bold green] ID: {vpc_id}")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
handle_error(e, "Error creating VPC network")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("get")
|
|
96
|
+
def network_get(
|
|
97
|
+
vpc_id: str = typer.Argument(..., help="VPC Network ID or name"),
|
|
98
|
+
format: OutputFormat = typer.Option(
|
|
99
|
+
OutputFormat.TABLE,
|
|
100
|
+
"--format", "-f",
|
|
101
|
+
help="Output format (table or json).",
|
|
102
|
+
case_sensitive=False,
|
|
103
|
+
),
|
|
104
|
+
):
|
|
105
|
+
"""Get details of a specific VPC network."""
|
|
106
|
+
set_output_format(format)
|
|
107
|
+
client = get_client()
|
|
108
|
+
try:
|
|
109
|
+
network = client.vpc_get(vpc_id)
|
|
110
|
+
print_output(network, table_render_func=_render_network_details_table)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
handle_error(e, "Error getting VPC network details")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("delete")
|
|
116
|
+
def network_delete(
|
|
117
|
+
vpc_id: str = typer.Argument(..., help="VPC Network ID or name to delete"),
|
|
118
|
+
):
|
|
119
|
+
"""Delete an organization VPC network."""
|
|
120
|
+
client = get_client()
|
|
121
|
+
try:
|
|
122
|
+
console.print(f"[cyan]Deleting VPC network {vpc_id}...[/cyan]")
|
|
123
|
+
res = client.vpc_delete(vpc_id)
|
|
124
|
+
console.print(f"[bold green]VPC network {vpc_id} deleted.[/bold green]")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
handle_error(e, "Error deleting VPC network")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import typer
|
|
2
|
-
|
|
2
|
+
import typer.core
|
|
3
|
+
from typing import Optional, List, Dict, Any
|
|
3
4
|
from rich.console import Console
|
|
4
5
|
from rich.table import Table, Column
|
|
5
6
|
import nodepick
|
|
@@ -8,8 +9,19 @@ from ..core.exceptions import handle_error
|
|
|
8
9
|
from ..core.formatters import OutputFormat, set_output_format, get_output_format, print_output
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
class NodeTyperGroup(typer.core.TyperGroup):
|
|
13
|
+
"""Custom TyperGroup allowing both 'np node <action> <node_id>' and 'np node <node_id> <action>'."""
|
|
14
|
+
def parse_args(self, ctx, args):
|
|
15
|
+
actions = {
|
|
16
|
+
"attach-ip", "detach-ip",
|
|
17
|
+
"boot", "reboot", "shutdown", "delete", "get"
|
|
18
|
+
}
|
|
19
|
+
if len(args) >= 2 and args[1] in actions and args[0] not in self.commands:
|
|
20
|
+
args = [args[1], args[0]] + list(args[2:])
|
|
21
|
+
return super().parse_args(ctx, args)
|
|
11
22
|
|
|
12
|
-
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(cls=NodeTyperGroup, name="node", help="Manage Compute Nodes.")
|
|
13
25
|
console = Console()
|
|
14
26
|
|
|
15
27
|
from click.core import ParameterSource
|
|
@@ -70,6 +82,10 @@ def _render_nodes_table(nodes, client=None):
|
|
|
70
82
|
)
|
|
71
83
|
console.print(table)
|
|
72
84
|
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
73
89
|
@app.command("boot")
|
|
74
90
|
def node_boot(
|
|
75
91
|
node_id: str = typer.Argument(..., help="Node ID or display name to boot"),
|
|
@@ -84,32 +100,131 @@ def node_boot(
|
|
|
84
100
|
handle_error(e, "Error booting node")
|
|
85
101
|
|
|
86
102
|
|
|
103
|
+
from .regions import validate_region, _render_regions_table
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_cpu(val: int) -> int:
|
|
107
|
+
if val < 1:
|
|
108
|
+
raise typer.BadParameter("Number of vCPUs must be at least 1.")
|
|
109
|
+
return val
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _validate_memory(val: int) -> int:
|
|
113
|
+
if val < 1:
|
|
114
|
+
raise typer.BadParameter("Memory size must be at least 1 GB.")
|
|
115
|
+
return val
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_storage(val: int) -> int:
|
|
119
|
+
if val < 10:
|
|
120
|
+
raise typer.BadParameter("Disk storage must be at least 10 GB.")
|
|
121
|
+
return val
|
|
122
|
+
|
|
123
|
+
|
|
87
124
|
@app.command("create")
|
|
88
125
|
def node_create(
|
|
89
126
|
display_name: Optional[str] = typer.Option(None, "--name", "-n", help="Display name for the node"),
|
|
90
|
-
cpu: int = typer.Option(1, "--cpu", "-c", help="Number of vCPUs"),
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
cpu: int = typer.Option(1, "--cpu", "-c", callback=_validate_cpu, help="Number of vCPUs (min: 1)"),
|
|
128
|
+
memory: int = typer.Option(1, "--memory", "-m", callback=_validate_memory, help="Memory size in GB (min: 1)"),
|
|
129
|
+
storage_gb: int = typer.Option(10, "--storage", "-s", callback=_validate_storage, help="Disk storage in GB (min: 10)"),
|
|
130
|
+
vpc: Optional[str] = typer.Option(None, "--vpc", help="VPC network ID or name to deploy node into"),
|
|
131
|
+
region: Optional[str] = typer.Option(None, "--region", "-r", help="Target region or datacenter (e.g. us-west-1, fmt1)"),
|
|
132
|
+
host_id: Optional[str] = typer.Option(None, "--host", "--host-id", "--system-id", help="Target Host ID to deploy onto"),
|
|
94
133
|
):
|
|
95
134
|
"""Deploy a new compute node."""
|
|
96
135
|
client = get_client()
|
|
97
|
-
|
|
136
|
+
if region:
|
|
137
|
+
validate_region(client, region)
|
|
98
138
|
try:
|
|
99
139
|
console.print("[cyan]Creating node...[/cyan]")
|
|
100
140
|
node = client.node_create(
|
|
101
|
-
memory=
|
|
141
|
+
memory=memory,
|
|
102
142
|
cpu=cpu,
|
|
103
|
-
network_type=network_type,
|
|
104
143
|
display_name=display_name,
|
|
105
144
|
storage_gb=storage_gb,
|
|
145
|
+
vpc=vpc,
|
|
146
|
+
region=region,
|
|
147
|
+
host_id=host_id,
|
|
106
148
|
)
|
|
107
|
-
node_id = node.get("vm_uuid")
|
|
149
|
+
node_id = node.get("vm_uuid") or node.get("id")
|
|
108
150
|
console.print(f"[bold green]Node created successfully![/bold green] ID: {node_id}")
|
|
109
151
|
except Exception as e:
|
|
110
152
|
handle_error(e, "Error creating node")
|
|
111
153
|
|
|
112
154
|
|
|
155
|
+
@app.command("attach-ip")
|
|
156
|
+
def node_attach_ip(
|
|
157
|
+
node_id: str = typer.Argument(..., help="Node ID or display name"),
|
|
158
|
+
ip: str = typer.Option(
|
|
159
|
+
"auto",
|
|
160
|
+
"--ip",
|
|
161
|
+
help="Public IP address to attach (IPv4 or IPv6), or 'auto' (IPv4) / 'auto-ipv6' (IPv6)",
|
|
162
|
+
),
|
|
163
|
+
):
|
|
164
|
+
"""Attach a public IP address (IPv4 or IPv6) to a compute node."""
|
|
165
|
+
client = get_client()
|
|
166
|
+
target_ip = ip
|
|
167
|
+
if isinstance(target_ip, str):
|
|
168
|
+
cleaned = target_ip.strip().lower()
|
|
169
|
+
if cleaned in ("auto-ipv6", "ipv6", "v6", "6"):
|
|
170
|
+
target_ip = "auto-ipv6"
|
|
171
|
+
elif cleaned in ("auto-ipv4", "ipv4", "v4", "4"):
|
|
172
|
+
target_ip = "auto"
|
|
173
|
+
|
|
174
|
+
is_v6_target = target_ip == "auto-ipv6" or ":" in target_ip
|
|
175
|
+
ver_target = "IPv6" if is_v6_target else "IPv4"
|
|
176
|
+
try:
|
|
177
|
+
console.print(f"[cyan]Attaching public {ver_target} to node {node_id}...[/cyan]")
|
|
178
|
+
res = client.node_attach_ip(node_id, ip=target_ip)
|
|
179
|
+
assigned_ip = res.get("ip") or "assigned"
|
|
180
|
+
is_v6_res = res.get("ipVersion") == 6 or ":" in str(assigned_ip)
|
|
181
|
+
ver_res = "IPv6" if is_v6_res else "IPv4"
|
|
182
|
+
console.print(f"[bold green]Public {ver_res} attached to node {node_id}:[/bold green] [cyan]{assigned_ip}[/cyan]")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
handle_error(e, f"Error attaching public {ver_target}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command("detach-ip")
|
|
188
|
+
def node_detach_ip(
|
|
189
|
+
node_id: str = typer.Argument(..., help="Node ID or display name"),
|
|
190
|
+
ip: Optional[str] = typer.Option(None, "--ip", help="Specific public IP address to detach (auto-detected if omitted)"),
|
|
191
|
+
ipv6: bool = typer.Option(False, "--ipv6", "-6", help="Detach public IPv6 address"),
|
|
192
|
+
):
|
|
193
|
+
"""Detach a public IP address (IPv4 or IPv6) from a compute node."""
|
|
194
|
+
client = get_client()
|
|
195
|
+
target_ip = ip
|
|
196
|
+
if not target_ip and ipv6:
|
|
197
|
+
try:
|
|
198
|
+
details = client.node_get_details(node_id)
|
|
199
|
+
connect = details.get("connect") or {}
|
|
200
|
+
candidate = connect.get("publicIpv6") or connect.get("ipv6")
|
|
201
|
+
if candidate:
|
|
202
|
+
target_ip = candidate
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
elif not target_ip:
|
|
206
|
+
try:
|
|
207
|
+
details = client.node_get_details(node_id)
|
|
208
|
+
connect = details.get("connect") or {}
|
|
209
|
+
pub4 = connect.get("publicIp")
|
|
210
|
+
pub6 = connect.get("publicIpv6") or connect.get("ipv6")
|
|
211
|
+
if pub6 and not pub4:
|
|
212
|
+
target_ip = pub6
|
|
213
|
+
elif pub4:
|
|
214
|
+
target_ip = pub4
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
is_v6 = (":" in target_ip) if target_ip else ipv6
|
|
219
|
+
ver_label = "IPv6" if is_v6 else "IPv4"
|
|
220
|
+
try:
|
|
221
|
+
console.print(f"[cyan]Detaching public {ver_label} from node {node_id}...[/cyan]")
|
|
222
|
+
res = client.node_detach_ip(node_id, ip=target_ip)
|
|
223
|
+
console.print(f"[bold green]Public {ver_label} detached from node {node_id}.[/bold green]")
|
|
224
|
+
except Exception as e:
|
|
225
|
+
handle_error(e, f"Error detaching public {ver_label}")
|
|
226
|
+
|
|
227
|
+
|
|
113
228
|
@app.command("delete")
|
|
114
229
|
def node_delete(
|
|
115
230
|
node_id: str = typer.Argument(..., help="Node ID or display name to delete"),
|