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.
- alchemax_plugin/__init__.py +3 -0
- alchemax_plugin/analyzer.py +83 -0
- alchemax_plugin/builder.py +211 -0
- alchemax_plugin/metadata.py +170 -0
- alchemax_plugin/runner.py +86 -0
- alchemax_plugin/server.py +713 -0
- alchemax_plugin/terraform/docker-compose.yml.tpl +23 -0
- alchemax_plugin/terraform/ebs.tf +43 -0
- alchemax_plugin/terraform/ec2.tf +102 -0
- alchemax_plugin/terraform/init.sh +138 -0
- alchemax_plugin/terraform/main.tf +42 -0
- alchemax_plugin/terraform/network.tf +98 -0
- alchemax_plugin/terraform/nginx.conf.tpl +103 -0
- alchemax_plugin/terraform/outputs.tf +41 -0
- alchemax_plugin/terraform/variables.tf +46 -0
- alchemax_plugin/tfstate.py +37 -0
- alchemax_plugin/workspace.py +66 -0
- alchemax_plugin-0.1.0.dist-info/METADATA +10 -0
- alchemax_plugin-0.1.0.dist-info/RECORD +21 -0
- alchemax_plugin-0.1.0.dist-info/WHEEL +4 -0
- alchemax_plugin-0.1.0.dist-info/entry_points.txt +2 -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
|