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.
@@ -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})