cloud-dock-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.
clouddock/cli.py ADDED
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from .prompts import collect_configuration, render_summary
11
+
12
+ TERRAFORM_REPO_URL = "https://github.com/DGclasher/cloud-dock"
13
+ CLOUDDOCK_HOME = Path.home() / ".clouddock"
14
+ TERRAFORM_DIRECTORY = CLOUDDOCK_HOME / "terraform"
15
+
16
+
17
+ class CloudDockPlanError(RuntimeError):
18
+ """Raised when plan orchestration fails."""
19
+
20
+
21
+ def check_prerequisites() -> dict[str, dict[str, bool | str | None]]:
22
+ status: dict[str, dict[str, bool | str | None]] = {
23
+ "git": {"installed": False, "version": None},
24
+ "terraform": {"installed": False, "version": None},
25
+ "aws": {"installed": False, "configured": False, "version": None},
26
+ }
27
+
28
+ git_path = shutil.which("git")
29
+ if git_path:
30
+ status["git"]["installed"] = True
31
+ result = subprocess.run(
32
+ ["git", "--version"],
33
+ check=False,
34
+ capture_output=True,
35
+ text=True,
36
+ )
37
+ if result.returncode == 0:
38
+ status["git"]["version"] = result.stdout.strip()
39
+
40
+ terraform_path = shutil.which("terraform")
41
+ if terraform_path:
42
+ status["terraform"]["installed"] = True
43
+ result = subprocess.run(
44
+ ["terraform", "version"],
45
+ check=False,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ if result.returncode == 0:
50
+ first_line = result.stdout.strip().splitlines(
51
+ )[0] if result.stdout.strip() else "Terraform"
52
+ status["terraform"]["version"] = first_line
53
+
54
+ aws_path = shutil.which("aws")
55
+ if aws_path:
56
+ status["aws"]["installed"] = True
57
+ result = subprocess.run(
58
+ ["aws", "sts", "get-caller-identity"],
59
+ check=False,
60
+ capture_output=True,
61
+ text=True,
62
+ )
63
+ status["aws"]["configured"] = result.returncode == 0
64
+
65
+ return status
66
+
67
+
68
+ def ensure_config_or_init(
69
+ config_path: Path,
70
+ *,
71
+ prompt_func=None,
72
+ init_runner=None,
73
+ ) -> Path:
74
+ if config_path.exists():
75
+ return config_path
76
+
77
+ if prompt_func is None:
78
+ prompt_func = lambda *_args, **_kwargs: typer.confirm(
79
+ "Would you like to run `clouddock init` now? [Y/n]",
80
+ default=False,
81
+ )
82
+ if init_runner is None:
83
+ init_runner = init
84
+
85
+ typer.echo("CloudDock configuration not found.")
86
+ typer.echo("")
87
+ if not prompt_func("Would you like to run `clouddock init` now? [Y/n]", default=False):
88
+ raise CloudDockPlanError(
89
+ "CloudDock configuration not found.\nRun `clouddock init` to create clouddock.yaml."
90
+ )
91
+
92
+ typer.echo("Running CloudDock init...")
93
+ init_runner()
94
+ if not config_path.exists():
95
+ raise CloudDockPlanError(
96
+ "CloudDock init did not produce a valid clouddock.yaml configuration."
97
+ )
98
+ return config_path
99
+
100
+
101
+ def ensure_terraform_repo(terraform_dir: Path) -> Path:
102
+ if terraform_dir.exists() and (terraform_dir / ".git").exists():
103
+ return terraform_dir
104
+
105
+ terraform_dir.parent.mkdir(parents=True, exist_ok=True)
106
+ if terraform_dir.exists() and not (terraform_dir / ".git").exists():
107
+ raise CloudDockPlanError(
108
+ f"Terraform directory exists but is not a valid Git checkout: {terraform_dir}"
109
+ )
110
+
111
+ result = subprocess.run(
112
+ ["git", "clone", TERRAFORM_REPO_URL, str(terraform_dir)],
113
+ check=False,
114
+ capture_output=True,
115
+ text=True,
116
+ )
117
+ if result.returncode != 0:
118
+ raise CloudDockPlanError(
119
+ f"Failed to clone Terraform repository: {TERRAFORM_REPO_URL}"
120
+ )
121
+ return terraform_dir
122
+
123
+
124
+ def copy_clouddock_config(source_path: Path, terraform_dir: Path) -> Path:
125
+ terraform_dir.mkdir(parents=True, exist_ok=True)
126
+ destination = terraform_dir / source_path.name
127
+ shutil.copy2(source_path, destination)
128
+ return destination
129
+
130
+
131
+ def run_terraform_command(args: list[str], working_directory: Path) -> subprocess.CompletedProcess[str]:
132
+ result = subprocess.run(args, cwd=str(
133
+ working_directory), check=False, text=True)
134
+ if result.stdout:
135
+ typer.echo(result.stdout, nl=False)
136
+ if result.stderr:
137
+ typer.echo(result.stderr, err=True, nl=False)
138
+ if result.returncode != 0:
139
+ command_name = " ".join(args)
140
+ combined_output = "\n".join(
141
+ chunk for chunk in [result.stdout.strip(), result.stderr.strip()] if chunk
142
+ )
143
+ lock_match = re.search(r"ID:\s*([A-Za-z0-9-]+)", combined_output)
144
+ lock_id = lock_match.group(1) if lock_match else "<lock-id>"
145
+ if "Error acquiring the state lock" in combined_output or "state lock" in combined_output.lower():
146
+ raise CloudDockPlanError(
147
+ f"{command_name} failed because Terraform state is locked. "
148
+ f"Stop the active Terraform process or run `terraform force-unlock -force {lock_id}` in "
149
+ f"{working_directory} to clear the stale lock."
150
+ )
151
+ raise CloudDockPlanError(
152
+ f"{command_name} failed in {working_directory}.")
153
+ return result
154
+
155
+
156
+ app = typer.Typer(help="CloudDock configuration generator")
157
+
158
+
159
+ @app.callback()
160
+ def callback() -> None:
161
+ """CloudDock CLI."""
162
+
163
+
164
+ @app.command()
165
+ def init() -> None:
166
+ """Interactively collect deployment requirements and generate a CloudDock configuration."""
167
+ config = collect_configuration()
168
+ typer.echo(render_summary(config))
169
+
170
+ save = typer.confirm("Save configuration?",
171
+ default=True, show_default=True)
172
+ if not save:
173
+ typer.echo("Configuration not saved.")
174
+ raise typer.Exit()
175
+
176
+ output_path = Path("clouddock.yaml")
177
+ if output_path.exists():
178
+ replace = typer.confirm(
179
+ "Configuration file already exists. Replace it?", default=False
180
+ )
181
+ if not replace:
182
+ typer.echo("Configuration not saved.")
183
+ raise typer.Exit()
184
+ output_path.unlink()
185
+
186
+ config.save_yaml(output_path)
187
+ typer.echo(f"Configuration saved to {output_path}")
188
+
189
+
190
+ @app.command()
191
+ def plan() -> None:
192
+ """Check prerequisites, prepare the Terraform repo, and run terraform plan."""
193
+ typer.echo("CloudDock deployment planner")
194
+ typer.echo("")
195
+ typer.echo("Checking prerequisites...")
196
+
197
+ status = check_prerequisites()
198
+ missing = []
199
+ labels = {
200
+ "git": "Git",
201
+ "terraform": "Terraform",
202
+ "aws": "AWS CLI",
203
+ }
204
+
205
+ for name in ("git", "terraform", "aws"):
206
+ info = status[name]
207
+ if not info.get("installed"):
208
+ missing.append(name)
209
+ typer.echo(f"✗ {labels[name]} not found")
210
+ continue
211
+
212
+ version = info.get("version")
213
+ if version:
214
+ typer.echo(f"✓ {labels[name]} {version}")
215
+ else:
216
+ typer.echo(f"✓ {labels[name]}")
217
+
218
+ if name == "aws" and not info.get("configured"):
219
+ typer.echo(
220
+ "✗ AWS CLI credentials/configuration could not be verified.")
221
+ typer.echo("Please configure AWS credentials and try again.")
222
+ missing.append("aws_config")
223
+
224
+ if missing:
225
+ raise typer.Exit(code=1)
226
+
227
+ typer.echo("")
228
+ typer.echo("Checking configuration...")
229
+ config_path = ensure_config_or_init(Path("clouddock.yaml"))
230
+ typer.echo(f"✓ clouddock.yaml found at {config_path}")
231
+
232
+ typer.echo("")
233
+ typer.echo("Preparing Terraform configuration...")
234
+ ensure_terraform_repo(TERRAFORM_DIRECTORY)
235
+ typer.echo(f"✓ Terraform repository ready: {TERRAFORM_DIRECTORY}")
236
+ copied_path = copy_clouddock_config(config_path, TERRAFORM_DIRECTORY)
237
+ typer.echo(f"✓ clouddock.yaml copied to {copied_path}")
238
+
239
+ typer.echo("")
240
+ typer.echo("Initializing Terraform...")
241
+ try:
242
+ run_terraform_command(["terraform", "init"], TERRAFORM_DIRECTORY)
243
+ except CloudDockPlanError as exc:
244
+ typer.echo("Terraform initialization failed.")
245
+ typer.echo("")
246
+ typer.echo("CloudDock stopped before running `terraform plan`.")
247
+ raise typer.Exit(code=1) from exc
248
+
249
+ typer.echo("")
250
+ typer.echo("Running Terraform plan...")
251
+ try:
252
+ run_terraform_command(["terraform", "plan"], TERRAFORM_DIRECTORY)
253
+ except CloudDockPlanError as exc:
254
+ typer.echo("Terraform plan failed.")
255
+ typer.echo("")
256
+ typer.echo(str(exc))
257
+ raise typer.Exit(code=1) from exc
258
+
259
+ typer.echo("")
260
+ typer.echo("Terraform plan completed.")
261
+
262
+
263
+ @app.command()
264
+ def deploy() -> None:
265
+ """Placeholder for future deployment support."""
266
+ typer.echo("CloudDock deploy is not implemented yet.")
267
+
268
+
269
+ if __name__ == "__main__":
270
+ app()
clouddock/config.py ADDED
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ APP_NAME_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]*$"
10
+ VALID_DATABASE_TYPES = {"postgres": "postgres", "mysql": "mysql"}
11
+ VALID_CACHE_TYPES = {"redis": "redis", "memcached": "memcached"}
12
+
13
+
14
+ def validate_application_name(value: str) -> str:
15
+ cleaned = value.strip()
16
+ if not cleaned:
17
+ raise ValueError("Application name is required.")
18
+ if len(cleaned) > 63:
19
+ raise ValueError("Application name must be 63 characters or fewer.")
20
+ if not cleaned.replace("-", "").replace("_", "").replace(".", "").isalnum():
21
+ raise ValueError(
22
+ "Application name may only contain letters, numbers, dots, underscores, and hyphens."
23
+ )
24
+ if not cleaned[0].isalnum():
25
+ raise ValueError(
26
+ "Application name must start with a letter or number.")
27
+ return cleaned
28
+
29
+
30
+ def validate_image(value: str) -> str:
31
+ cleaned = value.strip()
32
+ if not cleaned:
33
+ raise ValueError("Docker image is required.")
34
+ return cleaned
35
+
36
+
37
+ def validate_container_port(value: int | str) -> int:
38
+ try:
39
+ port = int(value)
40
+ except (TypeError, ValueError) as exc:
41
+ raise ValueError("Container port must be a valid integer.") from exc
42
+
43
+ if not 1 <= port <= 65535:
44
+ raise ValueError("Container port must be between 1 and 65535.")
45
+ return port
46
+
47
+
48
+ def validate_regions(value: list[str]) -> list[str]:
49
+ regions = [region.strip() for region in value]
50
+ if not regions or not any(region for region in regions):
51
+ raise ValueError("At least one AWS region is required.")
52
+ cleaned = [region for region in regions if region]
53
+ if not cleaned:
54
+ raise ValueError("At least one AWS region is required.")
55
+ return cleaned
56
+
57
+
58
+ def validate_desired_count(value: int | str) -> int:
59
+ try:
60
+ count = int(value)
61
+ except (TypeError, ValueError) as exc:
62
+ raise ValueError(
63
+ "Desired instance count must be a valid integer.") from exc
64
+
65
+ if count < 1:
66
+ raise ValueError("Desired instance count must be at least 1.")
67
+ return count
68
+
69
+
70
+ def validate_positive_int(value: int | str, field_name: str) -> int:
71
+ try:
72
+ number = int(value)
73
+ except (TypeError, ValueError) as exc:
74
+ raise ValueError(f"{field_name} must be a valid integer.") from exc
75
+
76
+ if number <= 0:
77
+ raise ValueError(f"{field_name} must be greater than 0.")
78
+ return number
79
+
80
+
81
+ def normalize_database_type(value: str) -> str:
82
+ normalized = value.strip().lower()
83
+ if normalized not in VALID_DATABASE_TYPES:
84
+ raise ValueError("Database type must be one of: PostgreSQL, MySQL.")
85
+ return VALID_DATABASE_TYPES[normalized]
86
+
87
+
88
+ def normalize_cache_type(value: str) -> str:
89
+ normalized = value.strip().lower()
90
+ if normalized not in VALID_CACHE_TYPES:
91
+ raise ValueError("Cache type must be one of: Redis, Memcached.")
92
+ return VALID_CACHE_TYPES[normalized]
93
+
94
+
95
+ @dataclass
96
+ class DatabaseConfig:
97
+ enabled: bool = False
98
+ type: str | None = None
99
+
100
+ def __post_init__(self) -> None:
101
+ if self.enabled and not self.type:
102
+ raise ValueError(
103
+ "Database type is required when database is enabled.")
104
+ if self.type is not None:
105
+ self.type = normalize_database_type(self.type)
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ if not self.enabled:
109
+ return {"enabled": False}
110
+ return {"enabled": True, "type": self.type}
111
+
112
+
113
+ @dataclass
114
+ class CacheConfig:
115
+ enabled: bool = False
116
+ type: str | None = None
117
+
118
+ def __post_init__(self) -> None:
119
+ if self.enabled and not self.type:
120
+ raise ValueError("Cache type is required when caching is enabled.")
121
+ if self.type is not None:
122
+ self.type = normalize_cache_type(self.type)
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ if not self.enabled:
126
+ return {"enabled": False}
127
+ return {"enabled": True, "type": self.type}
128
+
129
+
130
+ @dataclass
131
+ class ComputeConfig:
132
+ type: str
133
+ desired_count: int = 1
134
+ cpu: int = 256
135
+ memory: int = 512
136
+
137
+ def __post_init__(self) -> None:
138
+ self.type = self.type.strip().lower()
139
+ if self.type not in {"ecs"}:
140
+ raise ValueError("Compute type must be 'ecs'.")
141
+ self.desired_count = validate_desired_count(self.desired_count)
142
+ self.cpu = validate_positive_int(self.cpu, "CPU")
143
+ self.memory = validate_positive_int(self.memory, "Memory")
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ return {
147
+ "type": self.type,
148
+ "desired_count": self.desired_count,
149
+ "cpu": self.cpu,
150
+ "memory": self.memory,
151
+ }
152
+
153
+
154
+ @dataclass
155
+ class EnvironmentVariables:
156
+ values: dict[str, str] = field(default_factory=dict)
157
+
158
+ def __post_init__(self) -> None:
159
+ cleaned: dict[str, str] = {}
160
+ for key, value in self.values.items():
161
+ final_key = str(key).strip()
162
+ if not final_key:
163
+ raise ValueError("Environment variable keys cannot be empty.")
164
+ cleaned[final_key] = str(value)
165
+ self.values = cleaned
166
+
167
+ def as_dict(self) -> dict[str, str]:
168
+ return dict(self.values)
169
+
170
+ def to_dict(self) -> dict[str, str]:
171
+ return self.as_dict()
172
+
173
+
174
+ @dataclass
175
+ class CloudDockConfig:
176
+ application_name: str
177
+ image: str
178
+ container_port: int
179
+ regions: list[str]
180
+ compute: ComputeConfig
181
+ database: DatabaseConfig | None = None
182
+ cache: CacheConfig | None = None
183
+ environment_variables: EnvironmentVariables | None = None
184
+
185
+ def __post_init__(self) -> None:
186
+ self.application_name = validate_application_name(
187
+ self.application_name)
188
+ self.image = validate_image(self.image)
189
+ self.container_port = validate_container_port(self.container_port)
190
+ self.regions = validate_regions(self.regions)
191
+ if self.database is None:
192
+ self.database = DatabaseConfig(enabled=False)
193
+ if self.cache is None:
194
+ self.cache = CacheConfig(enabled=False)
195
+ if self.environment_variables is None:
196
+ self.environment_variables = EnvironmentVariables({})
197
+
198
+ def to_dict(self) -> dict[str, Any]:
199
+ return {
200
+ "application": {
201
+ "name": self.application_name,
202
+ "image": self.image,
203
+ "container_port": self.container_port,
204
+ },
205
+ "regions": self.regions,
206
+ "compute": self.compute.to_dict(),
207
+ "database": self.database.to_dict(),
208
+ "cache": self.cache.to_dict(),
209
+ "environment_variables": self.environment_variables.to_dict(),
210
+ }
211
+
212
+ def save_yaml(self, path: str | Path) -> Path:
213
+ target = Path(path)
214
+ if target.exists():
215
+ raise FileExistsError(
216
+ f"Configuration file already exists: {target}")
217
+
218
+ document = self.to_dict()
219
+ target.write_text(yaml.safe_dump(
220
+ document, sort_keys=False), encoding="utf-8")
221
+ return target