lcloud-cli 0.1.0__py3-none-any.whl
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.
- lambda_cloud/__init__.py +3 -0
- lambda_cloud/__main__.py +6 -0
- lambda_cloud/api/__init__.py +17 -0
- lambda_cloud/api/client.py +150 -0
- lambda_cloud/api/service.py +242 -0
- lambda_cloud/cli/__init__.py +1 -0
- lambda_cloud/cli/app.py +111 -0
- lambda_cloud/cli/commands/__init__.py +1 -0
- lambda_cloud/cli/commands/audit.py +37 -0
- lambda_cloud/cli/commands/auth.py +69 -0
- lambda_cloud/cli/commands/completion.py +37 -0
- lambda_cloud/cli/commands/config_cmd.py +45 -0
- lambda_cloud/cli/commands/filesystems.py +53 -0
- lambda_cloud/cli/commands/firewall.py +157 -0
- lambda_cloud/cli/commands/images.py +23 -0
- lambda_cloud/cli/commands/instance_types.py +19 -0
- lambda_cloud/cli/commands/instances.py +215 -0
- lambda_cloud/cli/commands/regions.py +19 -0
- lambda_cloud/cli/commands/ssh_keys.py +99 -0
- lambda_cloud/cli/state.py +66 -0
- lambda_cloud/cli/ui/__init__.py +13 -0
- lambda_cloud/cli/ui/console.py +94 -0
- lambda_cloud/cli/ui/history.py +62 -0
- lambda_cloud/cli/ui/tables.py +258 -0
- lambda_cloud/core/__init__.py +1 -0
- lambda_cloud/core/config.py +125 -0
- lambda_cloud/core/errors.py +58 -0
- lambda_cloud/mngr/__init__.py +5 -0
- lambda_cloud/mngr/models.py +198 -0
- lcloud_cli-0.1.0.dist-info/METADATA +217 -0
- lcloud_cli-0.1.0.dist-info/RECORD +34 -0
- lcloud_cli-0.1.0.dist-info/WHEEL +4 -0
- lcloud_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lcloud_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Inspect lambda-cloud CLI configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ...api.client import BASE_URL_ENV_VAR, DEFAULT_BASE_URL
|
|
9
|
+
from ...core.config import (
|
|
10
|
+
API_KEY_ENV_VAR,
|
|
11
|
+
config_path,
|
|
12
|
+
describe_api_key_source,
|
|
13
|
+
mask_api_key,
|
|
14
|
+
)
|
|
15
|
+
from ..state import CommandBase
|
|
16
|
+
from ..ui import history
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True, help="Inspect lambda-cloud configuration.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("show")
|
|
22
|
+
def show(ctx: typer.Context) -> None:
|
|
23
|
+
"""Show where the CLI reads its configuration from."""
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
cmd = CommandBase(ctx, needs_client=False)
|
|
27
|
+
source = describe_api_key_source(cmd.state.api_key)
|
|
28
|
+
|
|
29
|
+
table = Table(title="lambda-cloud configuration", show_header=False)
|
|
30
|
+
table.add_column("FIELD", style="bold cyan")
|
|
31
|
+
table.add_column("VALUE")
|
|
32
|
+
table.add_row("Config file", str(config_path()))
|
|
33
|
+
table.add_row("API base URL", os.environ.get(BASE_URL_ENV_VAR, DEFAULT_BASE_URL))
|
|
34
|
+
if source:
|
|
35
|
+
label, key = source
|
|
36
|
+
table.add_row("Key source", label)
|
|
37
|
+
table.add_row("API key", mask_api_key(key))
|
|
38
|
+
else:
|
|
39
|
+
table.add_row("Key source", f"not configured (login or set {API_KEY_ENV_VAR})")
|
|
40
|
+
|
|
41
|
+
last_launch = history.last_result("instances.launch")
|
|
42
|
+
if last_launch:
|
|
43
|
+
table.add_row("Last launch", str(last_launch["data"].get("instance_ids", "-")))
|
|
44
|
+
|
|
45
|
+
cmd.emit({"config_file": str(config_path())}, table)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Manage shared filesystems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ...api import service
|
|
8
|
+
from ..state import CommandBase
|
|
9
|
+
from ..ui.console import confirm_or_exit, success
|
|
10
|
+
from ..ui.tables import filesystems_table
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, help="Manage shared filesystems.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_filesystems(ctx: typer.Context) -> None:
|
|
17
|
+
"""List your filesystems."""
|
|
18
|
+
cmd = CommandBase(ctx)
|
|
19
|
+
filesystems = service.list_filesystems(cmd.client)
|
|
20
|
+
cmd.emit(filesystems, filesystems_table(filesystems))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("create")
|
|
24
|
+
def create_filesystem(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
name: str = typer.Option(..., "--name", "-n", help="Filesystem name."),
|
|
27
|
+
region: str = typer.Option(
|
|
28
|
+
..., "--region", "-r", help="Region (see `lambda-cloud regions list`)."
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Create a new filesystem."""
|
|
32
|
+
cmd = CommandBase(ctx)
|
|
33
|
+
filesystem = service.create_filesystem(cmd.client, name=name, region=region)
|
|
34
|
+
if cmd.output.value == "table":
|
|
35
|
+
success(f"Filesystem {filesystem.name!r} created (id: {filesystem.id}).")
|
|
36
|
+
else:
|
|
37
|
+
cmd.emit(filesystem)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("delete")
|
|
41
|
+
def delete_filesystem(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
filesystem_id: str = typer.Argument(..., help="ID of the filesystem to delete."),
|
|
44
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Delete a filesystem. It must not be mounted on any running instance."""
|
|
47
|
+
cmd = CommandBase(ctx)
|
|
48
|
+
confirm_or_exit(f"Delete filesystem {filesystem_id}?", yes)
|
|
49
|
+
service.delete_filesystem(cmd.client, filesystem_id)
|
|
50
|
+
if cmd.output.value == "table":
|
|
51
|
+
success(f"Filesystem {filesystem_id} deleted.")
|
|
52
|
+
else:
|
|
53
|
+
cmd.emit({"id": filesystem_id})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Manage firewall rulesets (regional and global)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from ...api import service
|
|
12
|
+
from ...core.errors import LambdaCloudError
|
|
13
|
+
from ...mngr.models import FirewallRule
|
|
14
|
+
from ..state import CommandBase
|
|
15
|
+
from ..ui.console import confirm_or_exit, success
|
|
16
|
+
from ..ui.tables import firewall_rules_table, rulesets_table
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True, help="Manage firewall rulesets.")
|
|
19
|
+
rulesets_app = typer.Typer(no_args_is_help=True, help="Manage regional firewall rulesets.")
|
|
20
|
+
global_app = typer.Typer(no_args_is_help=True, help="Manage the global firewall ruleset.")
|
|
21
|
+
app.add_typer(rulesets_app, name="rulesets")
|
|
22
|
+
app.add_typer(global_app, name="global")
|
|
23
|
+
|
|
24
|
+
_RULES_FILE_HELP = (
|
|
25
|
+
"Path to a JSON file containing a list of firewall rules, e.g. "
|
|
26
|
+
'[{"protocol": "tcp", "port_range": [22, 22], "source_network": "0.0.0.0/0", '
|
|
27
|
+
'"description": "SSH"}].'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_RULES_FILE_OPTION = dict(
|
|
31
|
+
exists=True,
|
|
32
|
+
file_okay=True,
|
|
33
|
+
dir_okay=False,
|
|
34
|
+
readable=True,
|
|
35
|
+
resolve_path=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_rules_file(path: Path) -> list[FirewallRule]:
|
|
40
|
+
"""Load and validate firewall rules from a JSON file."""
|
|
41
|
+
try:
|
|
42
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
except (OSError, ValueError) as exc:
|
|
44
|
+
raise LambdaCloudError(f"Cannot read rules file {path}: {exc}") from exc
|
|
45
|
+
if not isinstance(raw, list):
|
|
46
|
+
raise LambdaCloudError(f"Rules file {path} must contain a JSON list of rules.")
|
|
47
|
+
try:
|
|
48
|
+
return [FirewallRule.model_validate(item) for item in raw]
|
|
49
|
+
except ValidationError as exc:
|
|
50
|
+
raise LambdaCloudError(f"Invalid firewall rule in {path}: {exc}") from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@rulesets_app.command("list")
|
|
54
|
+
def list_rulesets(ctx: typer.Context) -> None:
|
|
55
|
+
"""List your firewall rulesets."""
|
|
56
|
+
cmd = CommandBase(ctx)
|
|
57
|
+
rulesets = service.list_firewall_rulesets(cmd.client)
|
|
58
|
+
cmd.emit(rulesets, rulesets_table(rulesets))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@rulesets_app.command("get")
|
|
62
|
+
def get_ruleset(
|
|
63
|
+
ctx: typer.Context, ruleset_id: str = typer.Argument(..., help="Ruleset ID.")
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Show a ruleset and its rules."""
|
|
66
|
+
cmd = CommandBase(ctx)
|
|
67
|
+
ruleset = service.get_firewall_ruleset(cmd.client, ruleset_id)
|
|
68
|
+
if cmd.output.value == "table":
|
|
69
|
+
cmd.emit(ruleset.rules, firewall_rules_table(ruleset.rules))
|
|
70
|
+
else:
|
|
71
|
+
cmd.emit(ruleset)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@rulesets_app.command("create")
|
|
75
|
+
def create_ruleset(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
name: str = typer.Option(..., "--name", "-n", help="Ruleset name."),
|
|
78
|
+
region: str = typer.Option(..., "--region", "-r", help="Region name."),
|
|
79
|
+
rules_file: Path = typer.Option(
|
|
80
|
+
..., "--rules-file", help=_RULES_FILE_HELP, **_RULES_FILE_OPTION
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Create a firewall ruleset from a JSON rules file."""
|
|
84
|
+
cmd = CommandBase(ctx)
|
|
85
|
+
rules = load_rules_file(rules_file)
|
|
86
|
+
ruleset = service.create_firewall_ruleset(cmd.client, name, region, rules)
|
|
87
|
+
if cmd.output.value == "table":
|
|
88
|
+
success(f"Ruleset {ruleset.name!r} created (id: {ruleset.id}).")
|
|
89
|
+
else:
|
|
90
|
+
cmd.emit(ruleset)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@rulesets_app.command("update")
|
|
94
|
+
def update_ruleset(
|
|
95
|
+
ctx: typer.Context,
|
|
96
|
+
ruleset_id: str = typer.Argument(..., help="Ruleset ID."),
|
|
97
|
+
name: str | None = typer.Option(None, "--name", "-n", help="New ruleset name."),
|
|
98
|
+
rules_file: Path | None = typer.Option(
|
|
99
|
+
None, "--rules-file", help=_RULES_FILE_HELP, **_RULES_FILE_OPTION
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Update a ruleset's name and/or rules (omitted fields stay unchanged)."""
|
|
103
|
+
cmd = CommandBase(ctx)
|
|
104
|
+
if name is None and rules_file is None:
|
|
105
|
+
raise LambdaCloudError("Nothing to update: pass --name and/or --rules-file.")
|
|
106
|
+
rules = load_rules_file(rules_file) if rules_file else None
|
|
107
|
+
ruleset = service.update_firewall_ruleset(cmd.client, ruleset_id, name=name, rules=rules)
|
|
108
|
+
if cmd.output.value == "table":
|
|
109
|
+
success(f"Ruleset {ruleset_id} updated.")
|
|
110
|
+
else:
|
|
111
|
+
cmd.emit(ruleset)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@rulesets_app.command("delete")
|
|
115
|
+
def delete_ruleset(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
ruleset_id: str = typer.Argument(..., help="Ruleset ID."),
|
|
118
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Delete a firewall ruleset."""
|
|
121
|
+
cmd = CommandBase(ctx)
|
|
122
|
+
confirm_or_exit(f"Delete firewall ruleset {ruleset_id}?", yes)
|
|
123
|
+
service.delete_firewall_ruleset(cmd.client, ruleset_id)
|
|
124
|
+
if cmd.output.value == "table":
|
|
125
|
+
success(f"Ruleset {ruleset_id} deleted.")
|
|
126
|
+
else:
|
|
127
|
+
cmd.emit({"id": ruleset_id})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@global_app.command("get")
|
|
131
|
+
def get_global_ruleset(ctx: typer.Context) -> None:
|
|
132
|
+
"""Show the global firewall ruleset."""
|
|
133
|
+
cmd = CommandBase(ctx)
|
|
134
|
+
ruleset = service.get_global_firewall_ruleset(cmd.client)
|
|
135
|
+
if cmd.output.value == "table":
|
|
136
|
+
cmd.emit(ruleset.rules, firewall_rules_table(ruleset.rules))
|
|
137
|
+
else:
|
|
138
|
+
cmd.emit(ruleset)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@global_app.command("update")
|
|
142
|
+
def update_global_ruleset(
|
|
143
|
+
ctx: typer.Context,
|
|
144
|
+
rules_file: Path = typer.Option(
|
|
145
|
+
..., "--rules-file", help=_RULES_FILE_HELP, **_RULES_FILE_OPTION
|
|
146
|
+
),
|
|
147
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Replace the rules of the global firewall ruleset."""
|
|
150
|
+
cmd = CommandBase(ctx)
|
|
151
|
+
rules = load_rules_file(rules_file)
|
|
152
|
+
confirm_or_exit(f"Replace the global firewall ruleset with {len(rules)} rule(s)?", yes)
|
|
153
|
+
service.update_global_firewall_ruleset(cmd.client, rules)
|
|
154
|
+
if cmd.output.value == "table":
|
|
155
|
+
success("Global firewall ruleset updated.")
|
|
156
|
+
else:
|
|
157
|
+
cmd.emit(service.get_global_firewall_ruleset(cmd.client))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""List available machine images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ...api import service
|
|
8
|
+
from ..state import CommandBase
|
|
9
|
+
from ..ui.tables import images_table
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, help="List available machine images.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_images(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
region: str | None = typer.Option(None, "--region", "-r", help="Only this region."),
|
|
18
|
+
family: str | None = typer.Option(None, "--family", help="Only this image family."),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""List available images (Lambda Stack and others)."""
|
|
21
|
+
cmd = CommandBase(ctx)
|
|
22
|
+
images = service.filter_images(service.list_images(cmd.client), region=region, family=family)
|
|
23
|
+
cmd.emit(images, images_table(service.sort_images_by_updated(images)))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""List available instance types, prices and capacity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ...api import service
|
|
8
|
+
from ..state import CommandBase
|
|
9
|
+
from ..ui.tables import instance_types_table
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, help="List instance types, prices and capacity.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_instance_types_cmd(ctx: typer.Context) -> None:
|
|
16
|
+
"""List every instance type with its specs, price and regional availability."""
|
|
17
|
+
cmd = CommandBase(ctx)
|
|
18
|
+
offers = service.list_instance_types(cmd.client)
|
|
19
|
+
cmd.emit(offers, instance_types_table(offers))
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Manage on-demand GPU instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
|
|
11
|
+
from ...api import service
|
|
12
|
+
from ...core.errors import LambdaCloudError
|
|
13
|
+
from ...mngr.models import TagEntry
|
|
14
|
+
from ..state import CommandBase
|
|
15
|
+
from ..ui import history
|
|
16
|
+
from ..ui.console import confirm_or_exit, console, success
|
|
17
|
+
from ..ui.tables import instance_detail_table, instances_table
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=True, help="Manage on-demand GPU instances.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_tag(tag: str) -> TagEntry:
|
|
23
|
+
"""Parse a ``key=value`` tag option."""
|
|
24
|
+
if "=" not in tag:
|
|
25
|
+
raise LambdaCloudError(f"Invalid tag {tag!r}: expected format key=value.")
|
|
26
|
+
key, value = tag.split("=", 1)
|
|
27
|
+
if not key:
|
|
28
|
+
raise LambdaCloudError(f"Invalid tag {tag!r}: key must not be empty.")
|
|
29
|
+
return TagEntry(key=key, value=value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_launch_payload(
|
|
33
|
+
*,
|
|
34
|
+
region: str,
|
|
35
|
+
instance_type: str,
|
|
36
|
+
ssh_key: str,
|
|
37
|
+
name: str | None = None,
|
|
38
|
+
hostname: str | None = None,
|
|
39
|
+
filesystems: list[str] | None = None,
|
|
40
|
+
image_id: str | None = None,
|
|
41
|
+
image_family: str | None = None,
|
|
42
|
+
user_data: str | None = None,
|
|
43
|
+
tags: list[TagEntry] | None = None,
|
|
44
|
+
firewall_rulesets: list[str] | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Assemble the request body for ``POST /instance-operations/launch``."""
|
|
47
|
+
if image_id and image_family:
|
|
48
|
+
raise LambdaCloudError("--image-id and --image-family are mutually exclusive.")
|
|
49
|
+
|
|
50
|
+
payload: dict[str, Any] = {
|
|
51
|
+
"region_name": region,
|
|
52
|
+
"instance_type_name": instance_type,
|
|
53
|
+
"ssh_key_names": [ssh_key],
|
|
54
|
+
}
|
|
55
|
+
if name is not None:
|
|
56
|
+
payload["name"] = name
|
|
57
|
+
if hostname is not None:
|
|
58
|
+
payload["hostname"] = hostname
|
|
59
|
+
if filesystems:
|
|
60
|
+
payload["file_system_names"] = filesystems
|
|
61
|
+
if image_id is not None:
|
|
62
|
+
payload["image"] = {"id": image_id}
|
|
63
|
+
elif image_family is not None:
|
|
64
|
+
payload["image"] = {"family": image_family}
|
|
65
|
+
if user_data is not None:
|
|
66
|
+
payload["user_data"] = user_data
|
|
67
|
+
if tags:
|
|
68
|
+
payload["tags"] = [tag.model_dump() for tag in tags]
|
|
69
|
+
if firewall_rulesets:
|
|
70
|
+
payload["firewall_rulesets"] = [{"id": ruleset_id} for ruleset_id in firewall_rulesets]
|
|
71
|
+
return payload
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("list")
|
|
75
|
+
def list_instances_cmd(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
cluster_id: str | None = typer.Option(None, "--cluster-id", help="Filter by cluster ID."),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""List running instances."""
|
|
80
|
+
cmd = CommandBase(ctx)
|
|
81
|
+
instances = service.list_instances(cmd.client, cluster_id=cluster_id)
|
|
82
|
+
cmd.state.history["instances"] = [instance.id for instance in instances]
|
|
83
|
+
cmd.emit(instances, instances_table(instances))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("get")
|
|
87
|
+
def get_instance(ctx: typer.Context, instance_id: str = typer.Argument(...)) -> None:
|
|
88
|
+
"""Show details of a single instance."""
|
|
89
|
+
cmd = CommandBase(ctx)
|
|
90
|
+
instance = service.get_instance(cmd.client, instance_id)
|
|
91
|
+
cmd.state.history["instance"] = instance.id
|
|
92
|
+
cmd.emit(instance, instance_detail_table(instance))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("launch")
|
|
96
|
+
def launch_instance(
|
|
97
|
+
ctx: typer.Context,
|
|
98
|
+
instance_type: str = typer.Option(
|
|
99
|
+
..., "--type", "-t", help="Instance type name (see `lambda-cloud types list`)."
|
|
100
|
+
),
|
|
101
|
+
region: str = typer.Option(
|
|
102
|
+
..., "--region", "-r", help="Region name (see `lambda-cloud regions list`)."
|
|
103
|
+
),
|
|
104
|
+
ssh_key: str = typer.Option(
|
|
105
|
+
...,
|
|
106
|
+
"--ssh-key",
|
|
107
|
+
"-s",
|
|
108
|
+
help="Name of an existing SSH key (see `lambda-cloud ssh-keys list`).",
|
|
109
|
+
),
|
|
110
|
+
name: str | None = typer.Option(None, "--name", help="Friendly name for the instance."),
|
|
111
|
+
hostname: str | None = typer.Option(
|
|
112
|
+
None, "--hostname", help="Hostname written to /etc/hostname."
|
|
113
|
+
),
|
|
114
|
+
filesystems: list[str] = typer.Option(
|
|
115
|
+
None, "--filesystem", "-f", help="Filesystem name to mount. Repeatable."
|
|
116
|
+
),
|
|
117
|
+
image_id: str | None = typer.Option(None, "--image-id", help="ID of a specific image."),
|
|
118
|
+
image_family: str | None = typer.Option(
|
|
119
|
+
None, "--image-family", help="Image family (defaults to the latest Lambda Stack)."
|
|
120
|
+
),
|
|
121
|
+
user_data: Path | None = typer.Option(
|
|
122
|
+
None,
|
|
123
|
+
"--user-data",
|
|
124
|
+
help="Path to a cloud-init user-data file.",
|
|
125
|
+
exists=True,
|
|
126
|
+
file_okay=True,
|
|
127
|
+
dir_okay=False,
|
|
128
|
+
readable=True,
|
|
129
|
+
resolve_path=True,
|
|
130
|
+
),
|
|
131
|
+
tags: list[str] = typer.Option(None, "--tag", help="Tag as key=value. Repeatable."),
|
|
132
|
+
firewall_rulesets: list[str] = typer.Option(
|
|
133
|
+
None, "--firewall-ruleset", help="Firewall ruleset ID (same region). Repeatable."
|
|
134
|
+
),
|
|
135
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Launch a new on-demand instance."""
|
|
138
|
+
cmd = CommandBase(ctx)
|
|
139
|
+
payload = build_launch_payload(
|
|
140
|
+
region=region,
|
|
141
|
+
instance_type=instance_type,
|
|
142
|
+
ssh_key=ssh_key,
|
|
143
|
+
name=name,
|
|
144
|
+
hostname=hostname,
|
|
145
|
+
filesystems=filesystems,
|
|
146
|
+
image_id=image_id,
|
|
147
|
+
image_family=image_family,
|
|
148
|
+
user_data=user_data.read_text() if user_data else None,
|
|
149
|
+
tags=[parse_tag(tag) for tag in tags or []],
|
|
150
|
+
firewall_rulesets=firewall_rulesets,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if cmd.output.value == "table":
|
|
154
|
+
summary = (
|
|
155
|
+
f"[bold]Type:[/bold] {instance_type}\n"
|
|
156
|
+
f"[bold]Region:[/bold] {region}\n"
|
|
157
|
+
f"[bold]SSH key:[/bold] {ssh_key}\n"
|
|
158
|
+
f"[bold]Name:[/bold] {name or '-'}"
|
|
159
|
+
)
|
|
160
|
+
console.print(Panel(summary, title="Launch instance"))
|
|
161
|
+
confirm_or_exit("Proceed with launch?", yes)
|
|
162
|
+
|
|
163
|
+
instance_ids = service.launch_instances(cmd.client, payload)
|
|
164
|
+
history.record_result("instances.launch", {"instance_ids": instance_ids})
|
|
165
|
+
if cmd.output.value == "table":
|
|
166
|
+
for instance_id in instance_ids:
|
|
167
|
+
success(f"Launch requested for instance {instance_id}")
|
|
168
|
+
else:
|
|
169
|
+
cmd.emit({"instance_ids": instance_ids})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("restart")
|
|
173
|
+
def restart_instances_cmd(
|
|
174
|
+
ctx: typer.Context,
|
|
175
|
+
instance_ids: list[str] = typer.Argument(..., help="IDs of instances to restart."),
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Restart one or more instances."""
|
|
178
|
+
cmd = CommandBase(ctx)
|
|
179
|
+
restarted = service.restart_instances(cmd.client, instance_ids)
|
|
180
|
+
cmd.emit(restarted, instances_table(restarted))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("terminate")
|
|
184
|
+
def terminate_instances_cmd(
|
|
185
|
+
ctx: typer.Context,
|
|
186
|
+
instance_ids: list[str] = typer.Argument(..., help="IDs of instances to terminate."),
|
|
187
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Terminate one or more instances. This cannot be undone."""
|
|
190
|
+
cmd = CommandBase(ctx)
|
|
191
|
+
confirm_or_exit(
|
|
192
|
+
f"Terminate {len(instance_ids)} instance(s): {', '.join(instance_ids)}?",
|
|
193
|
+
yes,
|
|
194
|
+
)
|
|
195
|
+
terminated = service.terminate_instances(cmd.client, instance_ids)
|
|
196
|
+
if cmd.output.value == "table":
|
|
197
|
+
for instance in terminated:
|
|
198
|
+
success(f"Terminating instance {instance.id}")
|
|
199
|
+
else:
|
|
200
|
+
cmd.emit({"terminated_instances": terminated})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command("rename")
|
|
204
|
+
def rename_instance(
|
|
205
|
+
ctx: typer.Context,
|
|
206
|
+
instance_id: str = typer.Argument(...),
|
|
207
|
+
name: str = typer.Option(..., "--name", help="New name (empty string to clear)."),
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Rename an instance."""
|
|
210
|
+
cmd = CommandBase(ctx)
|
|
211
|
+
service.rename_instance(cmd.client, instance_id, name)
|
|
212
|
+
if cmd.output.value == "table":
|
|
213
|
+
success(f"Instance {instance_id} renamed to {name!r}.")
|
|
214
|
+
else:
|
|
215
|
+
cmd.emit({"id": instance_id, "name": name})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""List available regions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ...api import service
|
|
8
|
+
from ..state import CommandBase
|
|
9
|
+
from ..ui.tables import regions_table
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, help="List available regions.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_regions(ctx: typer.Context) -> None:
|
|
16
|
+
"""List every region where instances can be launched."""
|
|
17
|
+
cmd = CommandBase(ctx)
|
|
18
|
+
regions = service.list_regions(cmd.client)
|
|
19
|
+
cmd.emit(regions, regions_table(regions))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Manage SSH keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ...api import service
|
|
11
|
+
from ...core.config import write_secret_file
|
|
12
|
+
from ...core.errors import LambdaCloudError
|
|
13
|
+
from ...mngr.models import GeneratedSSHKey
|
|
14
|
+
from ..state import CommandBase
|
|
15
|
+
from ..ui import history
|
|
16
|
+
from ..ui.console import confirm_or_exit, console, success, warn
|
|
17
|
+
from ..ui.tables import ssh_keys_table
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=True, help="Manage SSH keys.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("list")
|
|
23
|
+
def list_ssh_keys(ctx: typer.Context) -> None:
|
|
24
|
+
"""List your SSH keys."""
|
|
25
|
+
cmd = CommandBase(ctx)
|
|
26
|
+
keys = service.list_ssh_keys(cmd.client)
|
|
27
|
+
cmd.state.history["ssh_key_ids"] = [key.id for key in keys]
|
|
28
|
+
cmd.emit(keys, ssh_keys_table(keys))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("add")
|
|
32
|
+
def add_ssh_key(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
name: str = typer.Option(..., "--name", "-n", help="Name for the key."),
|
|
35
|
+
public_key: str | None = typer.Option(
|
|
36
|
+
None, "--public-key", help="Public key material (ssh-ed25519 AAAA…)."
|
|
37
|
+
),
|
|
38
|
+
key_file: Path | None = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--file",
|
|
41
|
+
help="Path to a public key file, e.g. ~/.ssh/id_ed25519.pub.",
|
|
42
|
+
exists=True,
|
|
43
|
+
file_okay=True,
|
|
44
|
+
dir_okay=False,
|
|
45
|
+
readable=True,
|
|
46
|
+
resolve_path=True,
|
|
47
|
+
),
|
|
48
|
+
save_to: Path | None = typer.Option(
|
|
49
|
+
None,
|
|
50
|
+
"--save-to",
|
|
51
|
+
help="Write the generated private key to this file (mode 0600).",
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Add an SSH key; generates a new key pair when no public key is given.
|
|
55
|
+
|
|
56
|
+
When a key pair is generated, the private key is returned once and is
|
|
57
|
+
NOT stored by Lambda: save it immediately.
|
|
58
|
+
"""
|
|
59
|
+
cmd = CommandBase(ctx)
|
|
60
|
+
if public_key and key_file:
|
|
61
|
+
raise LambdaCloudError("--public-key and --file are mutually exclusive.")
|
|
62
|
+
|
|
63
|
+
key_material = public_key or (
|
|
64
|
+
key_file.read_text(encoding="utf-8").strip() if key_file else None
|
|
65
|
+
)
|
|
66
|
+
key = service.add_ssh_key(cmd.client, name, public_key=key_material)
|
|
67
|
+
history.record_result("ssh_keys.add", {"id": key.id, "name": key.name})
|
|
68
|
+
|
|
69
|
+
if isinstance(key, GeneratedSSHKey):
|
|
70
|
+
if cmd.output.value == "json":
|
|
71
|
+
cmd.emit(key)
|
|
72
|
+
return
|
|
73
|
+
if save_to is not None:
|
|
74
|
+
write_secret_file(save_to, key.private_key + "\n")
|
|
75
|
+
os.chmod(save_to, 0o600)
|
|
76
|
+
success(f"Key pair generated. Private key saved to {save_to} (mode 0600).")
|
|
77
|
+
else:
|
|
78
|
+
warn("Lambda does NOT store the private key. Save it now:")
|
|
79
|
+
console.print(f"\n{key.private_key}\n", style="bold")
|
|
80
|
+
elif cmd.output.value == "table":
|
|
81
|
+
success(f"SSH key {key.name!r} added (id: {key.id}).")
|
|
82
|
+
else:
|
|
83
|
+
cmd.emit(key)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("delete")
|
|
87
|
+
def delete_ssh_key(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
key_id: str = typer.Argument(..., help="ID of the SSH key to delete."),
|
|
90
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Delete an SSH key."""
|
|
93
|
+
cmd = CommandBase(ctx)
|
|
94
|
+
confirm_or_exit(f"Delete SSH key {key_id}?", yes)
|
|
95
|
+
service.delete_ssh_key(cmd.client, key_id)
|
|
96
|
+
if cmd.output.value == "table":
|
|
97
|
+
success(f"SSH key {key_id} deleted.")
|
|
98
|
+
else:
|
|
99
|
+
cmd.emit({"id": key_id})
|