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 ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version # pragma: no cover
2
+
3
+ try:
4
+ __version__ = version(__name__)
5
+ except PackageNotFoundError: # pragma: no cover
6
+ __version__ = "unknown"
7
+ finally:
8
+ del version, PackageNotFoundError
ocea/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Convenience wrapper for ocea to run directly from source tree"""
2
+
3
+ from ocea.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
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)