ocea 2.0.8.dev2__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.
- ocea/__init__.py +8 -0
- ocea/__main__.py +6 -0
- ocea/_version.py +34 -0
- ocea/api.py +281 -0
- ocea/cli.py +90 -0
- ocea/commands/__init__.py +11 -0
- ocea/commands/down.py +229 -0
- ocea/commands/list_cmd.py +305 -0
- ocea/commands/new.py +115 -0
- ocea/commands/reboot.py +137 -0
- ocea/commands/snap.py +269 -0
- ocea/commands/status.py +188 -0
- ocea/commands/up.py +651 -0
- ocea/config.py +127 -0
- ocea/models/__init__.py +17 -0
- ocea/models/action.py +58 -0
- ocea/models/base.py +64 -0
- ocea/models/droplet.py +53 -0
- ocea/models/floating_ip.py +37 -0
- ocea/models/snapshot.py +41 -0
- ocea/services/__init__.py +6 -0
- ocea/services/database.py +553 -0
- ocea/services/digitalocean.py +580 -0
- ocea-2.0.8.dev2.dist-info/METADATA +307 -0
- ocea-2.0.8.dev2.dist-info/RECORD +30 -0
- ocea-2.0.8.dev2.dist-info/WHEEL +5 -0
- ocea-2.0.8.dev2.dist-info/entry_points.txt +2 -0
- ocea-2.0.8.dev2.dist-info/licenses/AUTHORS.rst +5 -0
- ocea-2.0.8.dev2.dist-info/licenses/LICENSE.txt +21 -0
- ocea-2.0.8.dev2.dist-info/top_level.txt +1 -0
ocea/__init__.py
ADDED
ocea/__main__.py
ADDED
ocea/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '2.0.8.dev2'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 0, 8, 'dev2')
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
ocea/api.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""OCEA Python API.
|
|
2
|
+
|
|
3
|
+
This module provides programmatic access to OCEA functionality
|
|
4
|
+
for use in Python scripts and applications.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from ocea.api import OCEA
|
|
8
|
+
|
|
9
|
+
ocea = OCEA()
|
|
10
|
+
droplets = ocea.list_droplets()
|
|
11
|
+
for d in droplets:
|
|
12
|
+
print(f"{d.name}: {d.status}")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ocea.config import get_config
|
|
18
|
+
from ocea.models.droplet import Droplet
|
|
19
|
+
from ocea.models.snapshot import Snapshot
|
|
20
|
+
from ocea.services.database import DatabaseService
|
|
21
|
+
from ocea.services.digitalocean import DOClient, DropletInfo, SnapshotInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OCEA:
|
|
25
|
+
"""Main API class for OCEA operations.
|
|
26
|
+
|
|
27
|
+
Provides a high-level interface for managing DigitalOcean droplets
|
|
28
|
+
and snapshots with local database tracking.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, api_token: str | None = None, db_path: Path | None = None):
|
|
32
|
+
"""Initialize OCEA API.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
api_token: DigitalOcean API token. If not provided, will be loaded
|
|
36
|
+
from environment variable DO_API_TOKEN or config file.
|
|
37
|
+
db_path: Path to SQLite database. If not provided, uses default
|
|
38
|
+
location (~/.local/share/ocea/ocea.db on Linux).
|
|
39
|
+
"""
|
|
40
|
+
self.config = get_config(db_path)
|
|
41
|
+
if api_token:
|
|
42
|
+
self.config.api_token = api_token
|
|
43
|
+
self.config.ensure_dirs()
|
|
44
|
+
|
|
45
|
+
self.db = DatabaseService(self.config.db_path)
|
|
46
|
+
self.db.init_db()
|
|
47
|
+
|
|
48
|
+
self._client: DOClient | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def client(self) -> DOClient:
|
|
52
|
+
"""Get the DigitalOcean client (lazy initialization)."""
|
|
53
|
+
if self._client is None:
|
|
54
|
+
self.config.validate()
|
|
55
|
+
self._client = DOClient(self.config.api_token)
|
|
56
|
+
return self._client
|
|
57
|
+
|
|
58
|
+
def list_droplets(self, sync: bool = True) -> list[DropletInfo]:
|
|
59
|
+
"""List all droplets from DigitalOcean.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
sync: If True, sync results to local database.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of DropletInfo instances.
|
|
66
|
+
"""
|
|
67
|
+
droplets = self.client.list_droplets()
|
|
68
|
+
|
|
69
|
+
if sync:
|
|
70
|
+
for d in droplets:
|
|
71
|
+
self.db.upsert_droplet(
|
|
72
|
+
do_id=d.do_id,
|
|
73
|
+
name=d.name,
|
|
74
|
+
region=d.region,
|
|
75
|
+
size=d.size,
|
|
76
|
+
image=d.image,
|
|
77
|
+
status=d.status,
|
|
78
|
+
public_ip=d.public_ip,
|
|
79
|
+
private_ip=d.private_ip,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return droplets
|
|
83
|
+
|
|
84
|
+
def list_droplets_local(self) -> list[Droplet]:
|
|
85
|
+
"""List droplets from local database only.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of Droplet model instances.
|
|
89
|
+
"""
|
|
90
|
+
return self.db.get_all_droplets()
|
|
91
|
+
|
|
92
|
+
def get_droplet(self, name: str) -> DropletInfo | None:
|
|
93
|
+
"""Get a droplet by name.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
name: Droplet name.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
DropletInfo or None if not found.
|
|
100
|
+
"""
|
|
101
|
+
droplets = self.client.list_droplets()
|
|
102
|
+
return next((d for d in droplets if d.name == name), None)
|
|
103
|
+
|
|
104
|
+
def create_droplet(
|
|
105
|
+
self,
|
|
106
|
+
name: str,
|
|
107
|
+
region: str,
|
|
108
|
+
size: str,
|
|
109
|
+
image: str,
|
|
110
|
+
ssh_keys: list[str] | None = None,
|
|
111
|
+
tags: list[str] | None = None,
|
|
112
|
+
) -> DropletInfo:
|
|
113
|
+
"""Create a new droplet.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
name: Droplet name.
|
|
117
|
+
region: Region slug (e.g., 'nyc1').
|
|
118
|
+
size: Size slug (e.g., 's-1vcpu-1gb').
|
|
119
|
+
image: Image slug or ID (e.g., 'ubuntu-24-04-x64').
|
|
120
|
+
ssh_keys: Optional list of SSH key IDs.
|
|
121
|
+
tags: Optional list of tags.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
DropletInfo for the created droplet.
|
|
125
|
+
"""
|
|
126
|
+
droplet = self.client.create_droplet(
|
|
127
|
+
name=name,
|
|
128
|
+
region=region,
|
|
129
|
+
size=size,
|
|
130
|
+
image=image,
|
|
131
|
+
ssh_keys=ssh_keys,
|
|
132
|
+
tags=tags,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.db.upsert_droplet(
|
|
136
|
+
do_id=droplet.do_id,
|
|
137
|
+
name=droplet.name,
|
|
138
|
+
region=droplet.region,
|
|
139
|
+
size=droplet.size,
|
|
140
|
+
image=droplet.image,
|
|
141
|
+
status=droplet.status,
|
|
142
|
+
public_ip=droplet.public_ip,
|
|
143
|
+
private_ip=droplet.private_ip,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return droplet
|
|
147
|
+
|
|
148
|
+
def power_on(self, name: str) -> bool:
|
|
149
|
+
"""Power on a droplet.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
name: Droplet name.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if power on was initiated.
|
|
156
|
+
"""
|
|
157
|
+
droplet = self.get_droplet(name)
|
|
158
|
+
if droplet is None:
|
|
159
|
+
raise ValueError(f"Droplet '{name}' not found")
|
|
160
|
+
|
|
161
|
+
result = self.client.power_on(droplet.do_id)
|
|
162
|
+
if result:
|
|
163
|
+
self.db.update_droplet_status(droplet.do_id, "active")
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def power_off(self, name: str) -> bool:
|
|
167
|
+
"""Power off a droplet (graceful shutdown).
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
name: Droplet name.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if shutdown was initiated.
|
|
174
|
+
"""
|
|
175
|
+
droplet = self.get_droplet(name)
|
|
176
|
+
if droplet is None:
|
|
177
|
+
raise ValueError(f"Droplet '{name}' not found")
|
|
178
|
+
|
|
179
|
+
result = self.client.shutdown(droplet.do_id)
|
|
180
|
+
if result:
|
|
181
|
+
self.db.update_droplet_status(droplet.do_id, "off")
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
def terminate(self, name: str, create_snapshot: bool = True) -> bool:
|
|
185
|
+
"""Terminate (destroy) a droplet.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
name: Droplet name.
|
|
189
|
+
create_snapshot: If True, create a snapshot before destroying.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if droplet was destroyed.
|
|
193
|
+
"""
|
|
194
|
+
droplet = self.get_droplet(name)
|
|
195
|
+
if droplet is None:
|
|
196
|
+
raise ValueError(f"Droplet '{name}' not found")
|
|
197
|
+
|
|
198
|
+
if create_snapshot:
|
|
199
|
+
from datetime import datetime
|
|
200
|
+
|
|
201
|
+
snapshot_name = f"{name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
202
|
+
self.create_snapshot(name, snapshot_name)
|
|
203
|
+
|
|
204
|
+
result = self.client.destroy(droplet.do_id)
|
|
205
|
+
if result:
|
|
206
|
+
self.db.update_droplet_status(droplet.do_id, "archive")
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
def create_snapshot(self, droplet_name: str, snapshot_name: str) -> SnapshotInfo | None:
|
|
210
|
+
"""Create a snapshot of a droplet.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
droplet_name: Name of the droplet to snapshot.
|
|
214
|
+
snapshot_name: Name for the snapshot.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
SnapshotInfo or None if failed.
|
|
218
|
+
"""
|
|
219
|
+
droplet = self.get_droplet(droplet_name)
|
|
220
|
+
if droplet is None:
|
|
221
|
+
raise ValueError(f"Droplet '{droplet_name}' not found")
|
|
222
|
+
|
|
223
|
+
snapshot = self.client.create_snapshot(droplet.do_id, snapshot_name)
|
|
224
|
+
if snapshot:
|
|
225
|
+
self.db.upsert_snapshot(
|
|
226
|
+
do_id=snapshot.do_id,
|
|
227
|
+
name=snapshot.name,
|
|
228
|
+
size_gigabytes=snapshot.size_gigabytes,
|
|
229
|
+
droplet_do_id=droplet.do_id,
|
|
230
|
+
)
|
|
231
|
+
return snapshot
|
|
232
|
+
|
|
233
|
+
def list_snapshots(self, sync: bool = True) -> list[SnapshotInfo]:
|
|
234
|
+
"""List all snapshots.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
sync: If True, sync results to local database.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of SnapshotInfo instances.
|
|
241
|
+
"""
|
|
242
|
+
snapshots = self.client.list_snapshots()
|
|
243
|
+
|
|
244
|
+
if sync:
|
|
245
|
+
for s in snapshots:
|
|
246
|
+
self.db.upsert_snapshot(
|
|
247
|
+
do_id=s.do_id,
|
|
248
|
+
name=s.name,
|
|
249
|
+
size_gigabytes=s.size_gigabytes,
|
|
250
|
+
droplet_do_id=s.resource_id,
|
|
251
|
+
created_at=s.created_at,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return snapshots
|
|
255
|
+
|
|
256
|
+
def list_snapshots_local(self) -> list[Snapshot]:
|
|
257
|
+
"""List snapshots from local database only.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of Snapshot model instances.
|
|
261
|
+
"""
|
|
262
|
+
return self.db.get_all_snapshots()
|
|
263
|
+
|
|
264
|
+
def delete_snapshot(self, snapshot_name: str) -> bool:
|
|
265
|
+
"""Delete a snapshot.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
snapshot_name: Snapshot name or ID.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
True if snapshot was deleted.
|
|
272
|
+
"""
|
|
273
|
+
snapshots = self.client.list_snapshots()
|
|
274
|
+
snapshot = next((s for s in snapshots if s.name == snapshot_name or s.do_id == snapshot_name), None)
|
|
275
|
+
if snapshot is None:
|
|
276
|
+
raise ValueError(f"Snapshot '{snapshot_name}' not found")
|
|
277
|
+
|
|
278
|
+
result = self.client.delete_snapshot(snapshot.do_id)
|
|
279
|
+
if result:
|
|
280
|
+
self.db.delete_snapshot(snapshot.do_id)
|
|
281
|
+
return result
|
ocea/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""OCEA CLI - DigitalOcean management with local inventory tracking.
|
|
2
|
+
|
|
3
|
+
To install run ``pip install .`` (or ``pip install -e .`` for editable mode)
|
|
4
|
+
which will install the command ``ocea`` inside your current environment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ocea import __version__
|
|
12
|
+
from ocea.commands import down, list_cmd, new, reboot, snap, status, up
|
|
13
|
+
|
|
14
|
+
__author__ = "Kevin Steptoe"
|
|
15
|
+
__copyright__ = "Kevin Steptoe"
|
|
16
|
+
__license__ = "MIT"
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_logging(loglevel: int) -> None:
|
|
22
|
+
"""Setup basic logging.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
loglevel: Minimum loglevel for emitting messages
|
|
26
|
+
"""
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s"
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
level=loglevel,
|
|
32
|
+
stream=sys.stderr,
|
|
33
|
+
format=logformat,
|
|
34
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
|
|
42
|
+
@click.version_option(__version__, "--version", "-V")
|
|
43
|
+
@click.option("-v", "--verbose", "loglevel", flag_value=logging.INFO, help="Enable verbose logging")
|
|
44
|
+
@click.option("-vv", "--debug", "loglevel", flag_value=logging.DEBUG, help="Enable debug logging")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def cli(ctx: click.Context, loglevel: int | None) -> None:
|
|
47
|
+
"""OCEA - DigitalOcean CLI with local inventory tracking.
|
|
48
|
+
|
|
49
|
+
Manage your DigitalOcean droplets and snapshots with a local SQLite database
|
|
50
|
+
for inventory tracking.
|
|
51
|
+
|
|
52
|
+
\b
|
|
53
|
+
Quick start:
|
|
54
|
+
export DO_API_TOKEN=your-token
|
|
55
|
+
ocea list # List all droplets
|
|
56
|
+
ocea status # Show local inventory
|
|
57
|
+
ocea new myserver -r nyc1 -s s-1vcpu-1gb -i ubuntu-24-04-x64
|
|
58
|
+
|
|
59
|
+
\b
|
|
60
|
+
Commands:
|
|
61
|
+
list List droplets from DigitalOcean
|
|
62
|
+
status Show local inventory status
|
|
63
|
+
new Create a new droplet
|
|
64
|
+
up Power on a droplet
|
|
65
|
+
down Power off or terminate a droplet
|
|
66
|
+
reboot Reboot a droplet
|
|
67
|
+
snap Create or manage snapshots
|
|
68
|
+
"""
|
|
69
|
+
ctx.ensure_object(dict)
|
|
70
|
+
if loglevel:
|
|
71
|
+
setup_logging(loglevel)
|
|
72
|
+
_logger.debug(f"OCEA version {__version__}")
|
|
73
|
+
|
|
74
|
+
# Show help if no command is provided
|
|
75
|
+
if ctx.invoked_subcommand is None:
|
|
76
|
+
click.echo(ctx.get_help())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Register commands
|
|
80
|
+
cli.add_command(list_cmd, name="list")
|
|
81
|
+
cli.add_command(up)
|
|
82
|
+
cli.add_command(down)
|
|
83
|
+
cli.add_command(new)
|
|
84
|
+
cli.add_command(reboot)
|
|
85
|
+
cli.add_command(snap)
|
|
86
|
+
cli.add_command(status)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
cli()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""CLI commands for OCEA."""
|
|
2
|
+
|
|
3
|
+
from ocea.commands.down import down
|
|
4
|
+
from ocea.commands.list_cmd import list_cmd
|
|
5
|
+
from ocea.commands.new import new
|
|
6
|
+
from ocea.commands.reboot import reboot
|
|
7
|
+
from ocea.commands.snap import snap
|
|
8
|
+
from ocea.commands.status import status
|
|
9
|
+
from ocea.commands.up import up
|
|
10
|
+
|
|
11
|
+
__all__ = ["list_cmd", "up", "down", "new", "reboot", "snap", "status"]
|
ocea/commands/down.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Down command - power off or terminate a droplet."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ocea.config import get_config
|
|
11
|
+
from ocea.services.database import DatabaseService
|
|
12
|
+
from ocea.services.digitalocean import DOClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format_elapsed(seconds: int) -> str:
|
|
16
|
+
"""Format elapsed seconds as human-readable string."""
|
|
17
|
+
if seconds < 60:
|
|
18
|
+
return f"{seconds}s"
|
|
19
|
+
minutes = seconds // 60
|
|
20
|
+
secs = seconds % 60
|
|
21
|
+
return f"{minutes}m {secs}s" if secs else f"{minutes}m"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_snapshot_base_name(name: str) -> str:
|
|
25
|
+
"""Extract the base droplet name, stripping auto-generated suffixes.
|
|
26
|
+
|
|
27
|
+
Strips DO console size/region suffixes (e.g. -s-2vcpu-8gb-160gb-intel-lon1-01)
|
|
28
|
+
and snapshot timestamp suffixes (-YYYYMMDD-HHMMSS) to prevent names from
|
|
29
|
+
snowballing across terminate/restore cycles.
|
|
30
|
+
"""
|
|
31
|
+
# Strip DO auto-generated size suffix (e.g. -s-2vcpu-8gb-...)
|
|
32
|
+
match = re.search(r"-s-\d+vcpu-", name)
|
|
33
|
+
if match:
|
|
34
|
+
name = name[: match.start()]
|
|
35
|
+
# Strip snapshot timestamp suffix (-YYYYMMDD-HHMMSS)
|
|
36
|
+
match = re.search(r"-\d{8}-\d{6}", name)
|
|
37
|
+
if match:
|
|
38
|
+
name = name[: match.start()]
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.command("down", context_settings={"help_option_names": ["-h", "--help"]})
|
|
43
|
+
@click.argument("identifier")
|
|
44
|
+
@click.option("--terminate", is_flag=True, help="Destroy the droplet (auto-snapshots first)")
|
|
45
|
+
@click.option("--no-snapshot", is_flag=True, help="Skip auto-snapshot when terminating")
|
|
46
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def down(
|
|
49
|
+
ctx: click.Context,
|
|
50
|
+
identifier: str,
|
|
51
|
+
terminate: bool,
|
|
52
|
+
no_snapshot: bool,
|
|
53
|
+
as_json: bool,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Power off or terminate a droplet.
|
|
56
|
+
|
|
57
|
+
IDENTIFIER is the droplet name or ID to power off.
|
|
58
|
+
|
|
59
|
+
By default, performs a graceful shutdown.
|
|
60
|
+
Use --terminate to destroy the droplet (creates snapshot first by default).
|
|
61
|
+
Use --terminate --no-snapshot to destroy without creating a snapshot.
|
|
62
|
+
"""
|
|
63
|
+
config = get_config()
|
|
64
|
+
console = Console()
|
|
65
|
+
try:
|
|
66
|
+
config.validate()
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
69
|
+
raise SystemExit(1) from None
|
|
70
|
+
config.ensure_dirs()
|
|
71
|
+
|
|
72
|
+
db = DatabaseService(config.db_path)
|
|
73
|
+
db.init_db()
|
|
74
|
+
|
|
75
|
+
client = DOClient(config.api_token)
|
|
76
|
+
|
|
77
|
+
# Find the droplet
|
|
78
|
+
droplet = db.get_droplet_by_name_or_id(identifier)
|
|
79
|
+
|
|
80
|
+
if droplet is None:
|
|
81
|
+
# Try to find via API
|
|
82
|
+
api_droplets = client.list_droplets()
|
|
83
|
+
api_droplet = next((d for d in api_droplets if d.name == identifier or d.do_id == identifier), None)
|
|
84
|
+
|
|
85
|
+
if api_droplet is None:
|
|
86
|
+
console.print(f"[red]Droplet '{identifier}' not found.[/red]")
|
|
87
|
+
raise SystemExit(1)
|
|
88
|
+
|
|
89
|
+
droplet_id = api_droplet.do_id
|
|
90
|
+
droplet_name = api_droplet.name
|
|
91
|
+
else:
|
|
92
|
+
droplet_id = droplet.do_id
|
|
93
|
+
droplet_name = droplet.name
|
|
94
|
+
|
|
95
|
+
# Get floating IP info before any changes (for restore later)
|
|
96
|
+
floating_ip = db.get_floating_ip_for_droplet(droplet_id)
|
|
97
|
+
floating_ip_addr = floating_ip.ip_address if floating_ip else None
|
|
98
|
+
|
|
99
|
+
if terminate:
|
|
100
|
+
# Terminate the droplet
|
|
101
|
+
snapshot_name = None
|
|
102
|
+
|
|
103
|
+
if not no_snapshot:
|
|
104
|
+
# Create snapshot before terminating
|
|
105
|
+
snapshot_name = f"{_get_snapshot_base_name(droplet_name)}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
106
|
+
console.print(f"[yellow]Creating snapshot '{snapshot_name}' before terminating...[/yellow]")
|
|
107
|
+
|
|
108
|
+
result = client.create_snapshot(droplet_id, snapshot_name)
|
|
109
|
+
if result:
|
|
110
|
+
action_id, snap_name = result
|
|
111
|
+
console.print(f"[green]Snapshot creation initiated: {snap_name}[/green]")
|
|
112
|
+
|
|
113
|
+
with console.status(
|
|
114
|
+
"[yellow]Waiting for snapshot to complete (this may take a few minutes)...[/yellow]"
|
|
115
|
+
) as spinner:
|
|
116
|
+
|
|
117
|
+
def _progress(elapsed: int, status: str) -> None:
|
|
118
|
+
spinner.update(
|
|
119
|
+
f"[yellow]Waiting for snapshot to complete[/yellow]"
|
|
120
|
+
f" [dim]· {status} · {_format_elapsed(elapsed)}[/dim]"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
snapshot = client.wait_for_snapshot(
|
|
124
|
+
action_id=action_id,
|
|
125
|
+
snapshot_name=snap_name,
|
|
126
|
+
droplet_id=droplet_id,
|
|
127
|
+
progress_callback=_progress,
|
|
128
|
+
)
|
|
129
|
+
if snapshot:
|
|
130
|
+
db.upsert_snapshot(
|
|
131
|
+
do_id=snapshot.do_id,
|
|
132
|
+
name=snapshot.name,
|
|
133
|
+
size_gigabytes=snapshot.size_gigabytes,
|
|
134
|
+
droplet_do_id=droplet_id,
|
|
135
|
+
)
|
|
136
|
+
console.print(f"[green]Snapshot '{snap_name}' completed.[/green]")
|
|
137
|
+
else:
|
|
138
|
+
console.print("[red]Snapshot failed or timed out. Aborting termination.[/red]")
|
|
139
|
+
raise SystemExit(1)
|
|
140
|
+
else:
|
|
141
|
+
console.print("[red]Failed to create snapshot. Aborting termination.[/red]")
|
|
142
|
+
raise SystemExit(1)
|
|
143
|
+
|
|
144
|
+
console.print(f"[yellow]Destroying droplet '{droplet_name}'...[/yellow]")
|
|
145
|
+
|
|
146
|
+
if client.destroy(droplet_id):
|
|
147
|
+
db.update_droplet_status(droplet_id, "archive")
|
|
148
|
+
|
|
149
|
+
# Log the terminate action with all metadata for future restore
|
|
150
|
+
db.log_action(
|
|
151
|
+
action_type="terminate",
|
|
152
|
+
droplet_do_id=droplet_id,
|
|
153
|
+
droplet_name=droplet_name,
|
|
154
|
+
snapshot_name=snapshot_name,
|
|
155
|
+
floating_ip=floating_ip_addr,
|
|
156
|
+
details={
|
|
157
|
+
"region": droplet.region if droplet else None,
|
|
158
|
+
"size": droplet.size if droplet else None,
|
|
159
|
+
"image": droplet.image if droplet else None,
|
|
160
|
+
"public_ip": droplet.public_ip if droplet else None,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if as_json:
|
|
165
|
+
result = {
|
|
166
|
+
"action": "terminated",
|
|
167
|
+
"droplet": droplet_name,
|
|
168
|
+
"do_id": droplet_id,
|
|
169
|
+
}
|
|
170
|
+
if snapshot_name:
|
|
171
|
+
result["snapshot"] = snapshot_name
|
|
172
|
+
if floating_ip_addr:
|
|
173
|
+
result["floating_ip_preserved"] = floating_ip_addr
|
|
174
|
+
click.echo(json.dumps(result, indent=2))
|
|
175
|
+
else:
|
|
176
|
+
console.print(f"[green]Droplet '{droplet_name}' has been terminated.[/green]")
|
|
177
|
+
if snapshot_name:
|
|
178
|
+
console.print(f"[dim]Snapshot '{snapshot_name}' is being created.[/dim]")
|
|
179
|
+
console.print(
|
|
180
|
+
"[dim]Note: Snapshots take several minutes to complete. Use 'ocea snap --list' to check.[/dim]"
|
|
181
|
+
)
|
|
182
|
+
if floating_ip_addr:
|
|
183
|
+
console.print(f"[dim]Floating IP {floating_ip_addr} preserved for restore.[/dim]")
|
|
184
|
+
else:
|
|
185
|
+
db.log_action(
|
|
186
|
+
action_type="terminate",
|
|
187
|
+
droplet_do_id=droplet_id,
|
|
188
|
+
droplet_name=droplet_name,
|
|
189
|
+
status="failed",
|
|
190
|
+
)
|
|
191
|
+
console.print(f"[red]Failed to destroy droplet '{droplet_name}'[/red]")
|
|
192
|
+
raise SystemExit(1)
|
|
193
|
+
|
|
194
|
+
else:
|
|
195
|
+
# Graceful shutdown
|
|
196
|
+
console.print(f"[yellow]Shutting down '{droplet_name}'...[/yellow]")
|
|
197
|
+
|
|
198
|
+
if client.shutdown(droplet_id):
|
|
199
|
+
db.update_droplet_status(droplet_id, "off")
|
|
200
|
+
db.log_action(
|
|
201
|
+
action_type="down",
|
|
202
|
+
droplet_do_id=droplet_id,
|
|
203
|
+
droplet_name=droplet_name,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if as_json:
|
|
207
|
+
click.echo(
|
|
208
|
+
json.dumps(
|
|
209
|
+
{
|
|
210
|
+
"action": "shutdown",
|
|
211
|
+
"droplet": droplet_name,
|
|
212
|
+
"do_id": droplet_id,
|
|
213
|
+
"status": "initiated",
|
|
214
|
+
},
|
|
215
|
+
indent=2,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
console.print(f"[green]Shutdown initiated for '{droplet_name}'[/green]")
|
|
220
|
+
console.print("[dim]Run 'ocea list' to check status.[/dim]")
|
|
221
|
+
else:
|
|
222
|
+
db.log_action(
|
|
223
|
+
action_type="down",
|
|
224
|
+
droplet_do_id=droplet_id,
|
|
225
|
+
droplet_name=droplet_name,
|
|
226
|
+
status="failed",
|
|
227
|
+
)
|
|
228
|
+
console.print(f"[red]Failed to shutdown '{droplet_name}'[/red]")
|
|
229
|
+
raise SystemExit(1)
|