alchemax-plugin 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,3 @@
1
+ """Alchemax plugin: standalone MCP toolkit for Docker app deployment to AWS EC2."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,83 @@
1
+ """Source code language and framework detection."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ class SourceAnalyzer:
9
+ """Analyzes source code for language, framework, and Dockerfile presence."""
10
+
11
+ @staticmethod
12
+ def analyze(source_path: Path) -> dict:
13
+ """Analyze source directory. Return lang, framework, has_dockerfile."""
14
+ if not source_path.exists():
15
+ return {"language": None, "framework": None, "has_dockerfile": False}
16
+
17
+ has_dockerfile = (source_path / "Dockerfile").exists()
18
+ if has_dockerfile:
19
+ return {"language": None, "framework": None, "has_dockerfile": True}
20
+
21
+ language, framework = None, None
22
+
23
+ # Check Node.js
24
+ pkg_json = source_path / "package.json"
25
+ if pkg_json.exists():
26
+ try:
27
+ with open(pkg_json) as f:
28
+ data = json.load(f)
29
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
30
+ if "next" in deps:
31
+ language, framework = "node", "next"
32
+ elif "express" in deps:
33
+ language, framework = "node", "express"
34
+ elif "react" in deps:
35
+ language, framework = "node", "react"
36
+ else:
37
+ language = "node"
38
+ except (json.JSONDecodeError, OSError):
39
+ language = "node"
40
+
41
+ # Check Python
42
+ if not language:
43
+ if (source_path / "requirements.txt").exists():
44
+ language = "python"
45
+ try:
46
+ with open(source_path / "requirements.txt") as f:
47
+ content = f.read()
48
+ if "fastapi" in content:
49
+ framework = "fastapi"
50
+ elif "flask" in content:
51
+ framework = "flask"
52
+ elif "django" in content:
53
+ framework = "django"
54
+ except OSError:
55
+ pass
56
+
57
+ elif (source_path / "pyproject.toml").exists():
58
+ language = "python"
59
+ try:
60
+ with open(source_path / "pyproject.toml") as f:
61
+ content = f.read()
62
+ if "fastapi" in content:
63
+ framework = "fastapi"
64
+ elif "flask" in content:
65
+ framework = "flask"
66
+ elif "django" in content:
67
+ framework = "django"
68
+ except OSError:
69
+ pass
70
+
71
+ # Check Go
72
+ if not language and (source_path / "go.mod").exists():
73
+ language = "go"
74
+
75
+ # Check Rust
76
+ if not language and (source_path / "Cargo.toml").exists():
77
+ language = "rust"
78
+
79
+ return {
80
+ "language": language,
81
+ "framework": framework,
82
+ "has_dockerfile": has_dockerfile,
83
+ }
@@ -0,0 +1,211 @@
1
+ """Docker image builder and registry push."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ class DockerAuthError(Exception):
12
+ """Raised when Docker registry authentication fails."""
13
+
14
+ pass
15
+
16
+
17
+ class DockerBuildError(Exception):
18
+ """Raised when Docker build fails."""
19
+
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class AnalysisResult:
25
+ """Result of source code analysis."""
26
+
27
+ has_dockerfile: bool
28
+ language: Optional[str]
29
+ framework: Optional[str]
30
+ suggested_dockerfile: Optional[str]
31
+
32
+
33
+ class DockerBuilder:
34
+ """Builds and pushes Docker images."""
35
+
36
+ def __init__(self, docker_hub_username: Optional[str] = None):
37
+ self.docker_hub_username = docker_hub_username or os.getenv("DOCKER_HUB_USERNAME")
38
+
39
+ def build(self, source_path: str, image_tag: str) -> str:
40
+ """Build Docker image with buildx. Hard-coded --platform linux/amd64."""
41
+ source_path = Path(source_path).resolve()
42
+ if not source_path.exists():
43
+ raise DockerBuildError(f"Source path does not exist: {source_path}")
44
+
45
+ cmd = [
46
+ "docker",
47
+ "buildx",
48
+ "build",
49
+ "--platform",
50
+ "linux/amd64",
51
+ "-t",
52
+ image_tag,
53
+ str(source_path),
54
+ ]
55
+ try:
56
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
57
+ except subprocess.CalledProcessError as e:
58
+ raise DockerBuildError(f"Build failed: {e.stderr}")
59
+
60
+ return image_tag
61
+
62
+ def push(self, image_tag: str) -> None:
63
+ """Push image to registry. Lazy auth check: only error on push failure."""
64
+ cmd = ["docker", "push", image_tag]
65
+ try:
66
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
67
+ except subprocess.CalledProcessError as e:
68
+ if "unauthorized" in e.stderr.lower():
69
+ raise DockerAuthError(
70
+ f"Docker registry authentication failed. Run `docker login` then retry."
71
+ )
72
+ raise DockerBuildError(f"Push failed: {e.stderr}")
73
+
74
+ def analyze(self, source_path: str) -> AnalysisResult:
75
+ """Analyze source code for language/framework and Dockerfile presence."""
76
+ source_path = Path(source_path)
77
+ if not source_path.exists():
78
+ return AnalysisResult(
79
+ has_dockerfile=False,
80
+ language=None,
81
+ framework=None,
82
+ suggested_dockerfile=None,
83
+ )
84
+
85
+ has_dockerfile = (source_path / "Dockerfile").exists()
86
+ if has_dockerfile:
87
+ return AnalysisResult(
88
+ has_dockerfile=True,
89
+ language=None,
90
+ framework=None,
91
+ suggested_dockerfile=None,
92
+ )
93
+
94
+ language = None
95
+ framework = None
96
+ suggested_dockerfile = None
97
+
98
+ # Check package.json (Node.js)
99
+ package_json = source_path / "package.json"
100
+ if package_json.exists():
101
+ try:
102
+ with open(package_json) as f:
103
+ data = json.load(f)
104
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
105
+ if "next" in deps:
106
+ language, framework = "node", "next"
107
+ elif "express" in deps:
108
+ language, framework = "node", "express"
109
+ elif "react" in deps:
110
+ language, framework = "node", "react"
111
+ else:
112
+ language = "node"
113
+ except (json.JSONDecodeError, OSError):
114
+ language = "node"
115
+
116
+ # Check Python
117
+ if not language and (source_path / "requirements.txt").exists():
118
+ language = "python"
119
+ try:
120
+ with open(source_path / "requirements.txt") as f:
121
+ content = f.read()
122
+ if "fastapi" in content:
123
+ framework = "fastapi"
124
+ elif "flask" in content:
125
+ framework = "flask"
126
+ elif "django" in content:
127
+ framework = "django"
128
+ except OSError:
129
+ pass
130
+
131
+ if not language and (source_path / "pyproject.toml").exists():
132
+ language = "python"
133
+ try:
134
+ with open(source_path / "pyproject.toml") as f:
135
+ content = f.read()
136
+ if "fastapi" in content:
137
+ framework = "fastapi"
138
+ elif "flask" in content:
139
+ framework = "flask"
140
+ elif "django" in content:
141
+ framework = "django"
142
+ except OSError:
143
+ pass
144
+
145
+ # Check Go
146
+ if not language and (source_path / "go.mod").exists():
147
+ language = "go"
148
+
149
+ # Check Rust
150
+ if not language and (source_path / "Cargo.toml").exists():
151
+ language = "rust"
152
+
153
+ # Generate suggested Dockerfile if language detected but no Dockerfile
154
+ if language and not has_dockerfile:
155
+ suggested_dockerfile = self._scaffold_dockerfile(language, framework)
156
+
157
+ return AnalysisResult(
158
+ has_dockerfile=has_dockerfile,
159
+ language=language,
160
+ framework=framework,
161
+ suggested_dockerfile=suggested_dockerfile,
162
+ )
163
+
164
+ @staticmethod
165
+ def _scaffold_dockerfile(language: str, framework: Optional[str]) -> str:
166
+ """Generate basic Dockerfile scaffold."""
167
+ if language == "node":
168
+ return """FROM node:24-alpine
169
+ WORKDIR /app
170
+ COPY package*.json ./
171
+ RUN npm ci --omit=dev
172
+ COPY . .
173
+ EXPOSE 8000
174
+ CMD ["npm", "start"]
175
+ """
176
+ elif language == "python":
177
+ return """FROM python:3.13-slim
178
+ WORKDIR /app
179
+ COPY requirements.txt .
180
+ RUN pip install -r requirements.txt
181
+ COPY . .
182
+ EXPOSE 8000
183
+ CMD ["python", "main.py"]
184
+ """
185
+ elif language == "go":
186
+ return """FROM golang:1.23-alpine AS builder
187
+ WORKDIR /app
188
+ COPY go.mod go.sum ./
189
+ RUN go mod download
190
+ COPY . .
191
+ RUN go build -o app
192
+
193
+ FROM alpine:latest
194
+ WORKDIR /app
195
+ COPY --from=builder /app/app .
196
+ EXPOSE 8000
197
+ CMD ["./app"]
198
+ """
199
+ elif language == "rust":
200
+ return """FROM rust:latest AS builder
201
+ WORKDIR /app
202
+ COPY . .
203
+ RUN cargo build --release
204
+
205
+ FROM debian:bookworm-slim
206
+ WORKDIR /app
207
+ COPY --from=builder /app/target/release/app .
208
+ EXPOSE 8000
209
+ CMD ["./app"]
210
+ """
211
+ return ""
@@ -0,0 +1,170 @@
1
+ """Project metadata (alchemax.json) parsing and validation."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class AppConfig:
11
+ """Single app configuration."""
12
+ id: str
13
+ port: int
14
+ path: str = "."
15
+ dockerfile: str = "Dockerfile"
16
+ tag: str = "latest"
17
+ health_check: Optional[str] = None
18
+ env: dict[str, str] = field(default_factory=dict)
19
+ secrets: list[str] = field(default_factory=list)
20
+ depends_on: list[str] = field(default_factory=list)
21
+
22
+ @property
23
+ def container_name(self) -> str:
24
+ """Deployed container name in docker-compose."""
25
+ return self.id
26
+
27
+ def validate(self) -> list[str]:
28
+ """Validate config. Return list of errors."""
29
+ errors = []
30
+ if not self.id:
31
+ errors.append("app.id is required")
32
+ if self.port < 1 or self.port > 65535:
33
+ errors.append(f"app.port {self.port} out of range")
34
+ if not self.path:
35
+ errors.append("app.path is required")
36
+ if not self.tag:
37
+ errors.append("app.tag is required")
38
+ return errors
39
+
40
+
41
+ @dataclass
42
+ class WorkspaceConfig:
43
+ """Workspace-level configuration."""
44
+ region: str = "us-east-1"
45
+ instance_type: str = "t3.micro"
46
+ ebs_volume_size: int = 10
47
+ env: dict[str, str] = field(default_factory=dict)
48
+
49
+ def validate(self) -> list[str]:
50
+ """Validate config."""
51
+ errors = []
52
+ if self.region not in [
53
+ "us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"
54
+ ]:
55
+ errors.append(f"workspace.region {self.region} not supported")
56
+ if self.instance_type not in ["t3.micro", "t3.small", "t3.medium"]:
57
+ errors.append(f"workspace.instance_type {self.instance_type} not supported")
58
+ if self.ebs_volume_size < 1 or self.ebs_volume_size > 1000:
59
+ errors.append(f"workspace.ebs_volume_size {self.ebs_volume_size} out of range")
60
+ return errors
61
+
62
+
63
+ @dataclass
64
+ class ProjectMetadata:
65
+ """Full project metadata from alchemax.json."""
66
+ name: str
67
+ apps: list[AppConfig] = field(default_factory=list)
68
+ workspace: WorkspaceConfig = field(default_factory=WorkspaceConfig)
69
+
70
+ def validate(self) -> list[str]:
71
+ """Validate entire manifest."""
72
+ errors = []
73
+ if not self.name:
74
+ errors.append("name is required")
75
+ if not self.apps:
76
+ errors.append("at least one app is required")
77
+
78
+ # Validate each app
79
+ app_ids = set()
80
+ for i, app in enumerate(self.apps):
81
+ app_errors = app.validate()
82
+ errors.extend([f"apps[{i}]: {e}" for e in app_errors])
83
+
84
+ if app.id in app_ids:
85
+ errors.append(f"apps[{i}]: duplicate id '{app.id}'")
86
+ app_ids.add(app.id)
87
+
88
+ # Validate depends_on references
89
+ for dep in app.depends_on:
90
+ if dep not in app_ids and dep not in [a.id for a in self.apps]:
91
+ errors.append(f"apps[{i}]: depends_on '{dep}' not found")
92
+
93
+ # Validate workspace
94
+ ws_errors = self.workspace.validate()
95
+ errors.extend([f"workspace: {e}" for e in ws_errors])
96
+
97
+ return errors
98
+
99
+ def deployment_order(self) -> list[str]:
100
+ """Return app IDs in dependency order (topological sort)."""
101
+ visited = set()
102
+ order = []
103
+
104
+ def visit(app_id: str) -> None:
105
+ if app_id in visited:
106
+ return
107
+ visited.add(app_id)
108
+
109
+ app = next((a for a in self.apps if a.id == app_id), None)
110
+ if not app:
111
+ return
112
+
113
+ for dep in app.depends_on:
114
+ visit(dep)
115
+
116
+ order.append(app_id)
117
+
118
+ for app in self.apps:
119
+ visit(app.id)
120
+
121
+ return order
122
+
123
+
124
+ def load_metadata(project_path: Path) -> Optional[ProjectMetadata]:
125
+ """Load alchemax.json from project. Return None if not found."""
126
+ manifest_file = Path(project_path) / "alchemax.json"
127
+ if not manifest_file.exists():
128
+ return None
129
+
130
+ try:
131
+ data = json.loads(manifest_file.read_text())
132
+ return parse_metadata(data)
133
+ except Exception:
134
+ return None
135
+
136
+
137
+ def parse_metadata(data: dict[str, Any]) -> ProjectMetadata:
138
+ """Parse metadata dict. Raise on invalid."""
139
+ name = data.get("name", "")
140
+
141
+ workspace_data = data.get("workspace", {})
142
+ workspace = WorkspaceConfig(
143
+ region=workspace_data.get("region", "us-east-1"),
144
+ instance_type=workspace_data.get("instance_type", "t3.micro"),
145
+ ebs_volume_size=workspace_data.get("ebs_volume_size", 10),
146
+ env=workspace_data.get("env", {}),
147
+ )
148
+
149
+ apps = []
150
+ for app_data in data.get("apps", []):
151
+ app = AppConfig(
152
+ id=app_data.get("id", ""),
153
+ port=app_data.get("port", 8000),
154
+ path=app_data.get("path", "."),
155
+ dockerfile=app_data.get("dockerfile", "Dockerfile"),
156
+ tag=app_data.get("tag", "latest"),
157
+ health_check=app_data.get("health_check"),
158
+ env=app_data.get("env", {}),
159
+ secrets=app_data.get("secrets", []),
160
+ depends_on=app_data.get("depends_on", []),
161
+ )
162
+ apps.append(app)
163
+
164
+ metadata = ProjectMetadata(name=name, apps=apps, workspace=workspace)
165
+
166
+ errors = metadata.validate()
167
+ if errors:
168
+ raise ValueError(f"Invalid metadata:\n" + "\n".join(errors))
169
+
170
+ return metadata
@@ -0,0 +1,86 @@
1
+ """Terraform runner for infrastructure provisioning."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+
9
+ class TerraformError(Exception):
10
+ """Raised when Terraform commands fail."""
11
+
12
+ pass
13
+
14
+
15
+ class TerraformRunner:
16
+ """Executes Terraform commands."""
17
+
18
+ def __init__(self, workspace_dir: Path):
19
+ self.workspace_dir = Path(workspace_dir)
20
+ if not self.workspace_dir.exists():
21
+ raise TerraformError(f"Workspace directory does not exist: {workspace_dir}")
22
+
23
+ def init(self) -> None:
24
+ """Initialize Terraform."""
25
+ cmd = ["terraform", "init"]
26
+ self._run_cmd(cmd, cwd=self.workspace_dir)
27
+
28
+ def apply(self, auto_approve: bool = False) -> dict[str, Any]:
29
+ """Apply Terraform changes. Return outputs."""
30
+ cmd = ["terraform", "apply"]
31
+ if auto_approve:
32
+ cmd.append("-auto-approve")
33
+
34
+ self._run_cmd(cmd, cwd=self.workspace_dir)
35
+ return self.output()
36
+
37
+ def destroy(self, auto_approve: bool = False) -> None:
38
+ """Destroy Terraform resources."""
39
+ cmd = ["terraform", "destroy"]
40
+ if auto_approve:
41
+ cmd.append("-auto-approve")
42
+
43
+ self._run_cmd(cmd, cwd=self.workspace_dir)
44
+
45
+ def output(self) -> dict[str, Any]:
46
+ """Get Terraform outputs as JSON."""
47
+ cmd = ["terraform", "output", "-json"]
48
+ result = subprocess.run(
49
+ cmd,
50
+ cwd=self.workspace_dir,
51
+ capture_output=True,
52
+ text=True,
53
+ check=False,
54
+ )
55
+
56
+ if result.returncode != 0:
57
+ if "no output" in result.stdout.lower() or result.stdout.strip() == "{}":
58
+ return {}
59
+ raise TerraformError(f"Terraform output failed: {result.stderr}")
60
+
61
+ try:
62
+ data = json.loads(result.stdout)
63
+ # Extract raw values from Terraform output format: {"key": {"value": ...}}
64
+ return {k: v.get("value") if isinstance(v, dict) else v for k, v in data.items()}
65
+ except json.JSONDecodeError:
66
+ return {}
67
+
68
+ def validate(self) -> bool:
69
+ """Validate Terraform configuration."""
70
+ cmd = ["terraform", "validate", "-json"]
71
+ result = subprocess.run(
72
+ cmd,
73
+ cwd=self.workspace_dir,
74
+ capture_output=True,
75
+ text=True,
76
+ check=False,
77
+ )
78
+ return result.returncode == 0
79
+
80
+ @staticmethod
81
+ def _run_cmd(cmd: list[str], cwd: Path) -> str:
82
+ """Run command and raise on failure."""
83
+ result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
84
+ if result.returncode != 0:
85
+ raise TerraformError(f"Command failed: {result.stderr}")
86
+ return result.stdout