agentdeploy 0.1.1__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.
- agentdeploy/__init__.py +32 -0
- agentdeploy/adapters/__init__.py +6 -0
- agentdeploy/adapters/base.py +46 -0
- agentdeploy/adapters/registry.py +221 -0
- agentdeploy/cli/__init__.py +5 -0
- agentdeploy/cli/main.py +266 -0
- agentdeploy/core/__init__.py +1 -0
- agentdeploy/core/app.py +104 -0
- agentdeploy/core/deploy.py +96 -0
- agentdeploy/core/hitl.py +203 -0
- agentdeploy/core/observability.py +160 -0
- agentdeploy/targets/__init__.py +17 -0
- agentdeploy/targets/cloud_run.py +128 -0
- agentdeploy/targets/docker_compose.py +145 -0
- agentdeploy/targets/kubernetes.py +344 -0
- agentdeploy/targets/lambda_target.py +160 -0
- agentdeploy-0.1.1.dist-info/METADATA +337 -0
- agentdeploy-0.1.1.dist-info/RECORD +21 -0
- agentdeploy-0.1.1.dist-info/WHEEL +4 -0
- agentdeploy-0.1.1.dist-info/entry_points.txt +2 -0
- agentdeploy-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DockerComposeTarget — generates docker-compose.yml for local development
|
|
3
|
+
and staging. Includes the agent container, an optional Redis sidecar
|
|
4
|
+
for inter-agent state, and an OTEL collector sidecar.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from agentdeploy.core.app import AgentApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DockerComposeTarget:
|
|
19
|
+
app: AgentApp
|
|
20
|
+
output_dir: str = "."
|
|
21
|
+
network: str = "agent-net"
|
|
22
|
+
|
|
23
|
+
_redis: bool = field(default=False, init=False)
|
|
24
|
+
_otel_collector: bool = field(default=False, init=False)
|
|
25
|
+
_extra_services: dict = field(default_factory=dict, init=False)
|
|
26
|
+
|
|
27
|
+
def with_redis(self) -> DockerComposeTarget:
|
|
28
|
+
"""Add a Redis sidecar for shared agent state / queue."""
|
|
29
|
+
self._redis = True
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def with_otel_collector(self) -> DockerComposeTarget:
|
|
33
|
+
"""Add an OpenTelemetry collector sidecar for local trace collection."""
|
|
34
|
+
self._otel_collector = True
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def with_service(self, name: str, definition: dict) -> DockerComposeTarget:
|
|
38
|
+
"""Add an arbitrary extra service to the compose file."""
|
|
39
|
+
self._extra_services[name] = definition
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def build(self) -> DockerComposeResult:
|
|
43
|
+
cfg = self.app.to_config()
|
|
44
|
+
adapter = self.app._adapter
|
|
45
|
+
out = Path(self.output_dir) / cfg.name
|
|
46
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
dockerfile = self._generate_dockerfile(cfg, adapter)
|
|
49
|
+
(out / "Dockerfile").write_text(dockerfile)
|
|
50
|
+
|
|
51
|
+
server_code = adapter.entrypoint_code(cfg.name, self.app._port)
|
|
52
|
+
(out / "server.py").write_text(server_code)
|
|
53
|
+
|
|
54
|
+
compose = self._compose_manifest(cfg)
|
|
55
|
+
compose_path = out / "docker-compose.yml"
|
|
56
|
+
compose_path.write_text(yaml.dump(compose, default_flow_style=False))
|
|
57
|
+
|
|
58
|
+
return DockerComposeResult(
|
|
59
|
+
app_name=cfg.name,
|
|
60
|
+
output_dir=str(out),
|
|
61
|
+
compose_path=str(compose_path),
|
|
62
|
+
next_steps=[
|
|
63
|
+
f"cd {out}",
|
|
64
|
+
"docker compose up --build",
|
|
65
|
+
f"curl http://localhost:{self.app._port}{self.app._health_path}",
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _generate_dockerfile(self, cfg, adapter) -> str:
|
|
70
|
+
extras = adapter.pip_extras()
|
|
71
|
+
pip_install = " ".join(extras) if extras else ""
|
|
72
|
+
return f"""FROM python:3.11-slim
|
|
73
|
+
WORKDIR /app
|
|
74
|
+
RUN pip install --no-cache-dir fastapi uvicorn{(" " + pip_install) if pip_install else ""}
|
|
75
|
+
COPY . .
|
|
76
|
+
EXPOSE {self.app._port}
|
|
77
|
+
CMD ["python", "server.py"]
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def _compose_manifest(self, cfg) -> dict:
|
|
81
|
+
depends_on = []
|
|
82
|
+
services: dict = {}
|
|
83
|
+
|
|
84
|
+
agent_env = dict(cfg.env_vars)
|
|
85
|
+
if self._redis:
|
|
86
|
+
agent_env["REDIS_URL"] = "redis://redis:6379"
|
|
87
|
+
depends_on.append("redis")
|
|
88
|
+
if self._otel_collector:
|
|
89
|
+
agent_env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://otel-collector:4317"
|
|
90
|
+
depends_on.append("otel-collector")
|
|
91
|
+
|
|
92
|
+
probe_url = f"http://localhost:{self.app._port}{self.app._health_path}"
|
|
93
|
+
# Stdlib urllib probe — no curl required, no apt-get layer in the image.
|
|
94
|
+
probe_script = (
|
|
95
|
+
"import urllib.request,sys; "
|
|
96
|
+
f"sys.exit(0 if urllib.request.urlopen('{probe_url}',timeout=5)"
|
|
97
|
+
".status==200 else 1)"
|
|
98
|
+
)
|
|
99
|
+
agent_service: dict = {
|
|
100
|
+
"build": ".",
|
|
101
|
+
"ports": [f"{self.app._port}:{self.app._port}"],
|
|
102
|
+
"environment": agent_env,
|
|
103
|
+
"networks": [self.network],
|
|
104
|
+
"restart": "unless-stopped",
|
|
105
|
+
"healthcheck": {
|
|
106
|
+
"test": ["CMD", "python", "-c", probe_script],
|
|
107
|
+
"interval": "30s",
|
|
108
|
+
"timeout": "10s",
|
|
109
|
+
"retries": 3,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
if depends_on:
|
|
113
|
+
agent_service["depends_on"] = depends_on
|
|
114
|
+
|
|
115
|
+
services[cfg.name] = agent_service
|
|
116
|
+
|
|
117
|
+
if self._redis:
|
|
118
|
+
services["redis"] = {
|
|
119
|
+
"image": "redis:7-alpine",
|
|
120
|
+
"networks": [self.network],
|
|
121
|
+
"restart": "unless-stopped",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if self._otel_collector:
|
|
125
|
+
services["otel-collector"] = {
|
|
126
|
+
"image": "otel/opentelemetry-collector-contrib:latest",
|
|
127
|
+
"networks": [self.network],
|
|
128
|
+
"ports": ["4317:4317", "4318:4318"],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
services.update(self._extra_services)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"version": "3.9",
|
|
135
|
+
"services": services,
|
|
136
|
+
"networks": {self.network: {"driver": "bridge"}},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class DockerComposeResult:
|
|
142
|
+
app_name: str
|
|
143
|
+
output_dir: str
|
|
144
|
+
compose_path: str
|
|
145
|
+
next_steps: list[str]
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KubernetesTarget — generates production-ready Kubernetes manifests
|
|
3
|
+
and a Dockerfile from an AgentApp.
|
|
4
|
+
|
|
5
|
+
Outputs:
|
|
6
|
+
./deploy/<name>/Dockerfile
|
|
7
|
+
./deploy/<name>/k8s/deployment.yaml
|
|
8
|
+
./deploy/<name>/k8s/service.yaml
|
|
9
|
+
./deploy/<name>/k8s/hpa.yaml
|
|
10
|
+
./deploy/<name>/k8s/secret.yaml (if secrets declared)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from agentdeploy.core.app import AgentApp
|
|
21
|
+
from agentdeploy.core.hitl import HITLConfig
|
|
22
|
+
from agentdeploy.core.observability import OtelConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class KubernetesTarget:
|
|
27
|
+
app: AgentApp
|
|
28
|
+
namespace: str = "default"
|
|
29
|
+
image: str = ""
|
|
30
|
+
registry: str = ""
|
|
31
|
+
|
|
32
|
+
_replicas: int = field(default=1, init=False)
|
|
33
|
+
_autoscale_min: int = field(default=1, init=False)
|
|
34
|
+
_autoscale_max: int = field(default=5, init=False)
|
|
35
|
+
_autoscale_cpu_pct: int = field(default=70, init=False)
|
|
36
|
+
_hitl: HITLConfig | None = field(default=None, init=False)
|
|
37
|
+
_otel: OtelConfig | None = field(default=None, init=False)
|
|
38
|
+
_output_dir: str = field(default="./deploy", init=False)
|
|
39
|
+
_python_version: str = field(default="3.11", init=False)
|
|
40
|
+
_base_image: str = field(default="python:3.11-slim", init=False)
|
|
41
|
+
|
|
42
|
+
def with_replicas(self, count: int) -> KubernetesTarget:
|
|
43
|
+
self._replicas = count
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def with_autoscale(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
min: int = 1,
|
|
50
|
+
max: int = 10,
|
|
51
|
+
cpu_percent: int = 70,
|
|
52
|
+
) -> KubernetesTarget:
|
|
53
|
+
self._autoscale_min = min
|
|
54
|
+
self._autoscale_max = max
|
|
55
|
+
self._autoscale_cpu_pct = cpu_percent
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def with_hitl_gate(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
webhook: str = "",
|
|
62
|
+
slack_channel: str = "",
|
|
63
|
+
timeout_seconds: int = 3600,
|
|
64
|
+
) -> KubernetesTarget:
|
|
65
|
+
self._hitl = HITLConfig(
|
|
66
|
+
webhook=webhook,
|
|
67
|
+
slack_channel=slack_channel,
|
|
68
|
+
timeout_seconds=timeout_seconds,
|
|
69
|
+
)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def with_telemetry(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
endpoint: str = "http://otel-collector:4317",
|
|
76
|
+
service_name: str = "",
|
|
77
|
+
) -> KubernetesTarget:
|
|
78
|
+
self._otel = OtelConfig(
|
|
79
|
+
endpoint=endpoint,
|
|
80
|
+
service_name=service_name or self.app.name,
|
|
81
|
+
)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def with_output_dir(self, path: str) -> KubernetesTarget:
|
|
85
|
+
self._output_dir = path
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def with_base_image(self, image: str) -> KubernetesTarget:
|
|
89
|
+
self._base_image = image
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def build(self) -> BuildResult:
|
|
93
|
+
"""Generate all deployment artifacts and write them to disk."""
|
|
94
|
+
cfg = self.app.to_config()
|
|
95
|
+
adapter = self.app._adapter
|
|
96
|
+
out = Path(self._output_dir) / cfg.name
|
|
97
|
+
k8s_dir = out / "k8s"
|
|
98
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
k8s_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
files: list[Path] = []
|
|
102
|
+
|
|
103
|
+
dockerfile = self._generate_dockerfile(cfg, adapter)
|
|
104
|
+
df_path = out / "Dockerfile"
|
|
105
|
+
df_path.write_text(dockerfile)
|
|
106
|
+
files.append(df_path)
|
|
107
|
+
|
|
108
|
+
server_code = adapter.entrypoint_code(cfg.name, self.app._port)
|
|
109
|
+
server_path = out / "server.py"
|
|
110
|
+
server_path.write_text(server_code)
|
|
111
|
+
files.append(server_path)
|
|
112
|
+
|
|
113
|
+
deploy_manifest = self._deployment_manifest(cfg)
|
|
114
|
+
dep_path = k8s_dir / "deployment.yaml"
|
|
115
|
+
dep_path.write_text(yaml.dump(deploy_manifest, default_flow_style=False))
|
|
116
|
+
files.append(dep_path)
|
|
117
|
+
|
|
118
|
+
svc_manifest = self._service_manifest(cfg)
|
|
119
|
+
svc_path = k8s_dir / "service.yaml"
|
|
120
|
+
svc_path.write_text(yaml.dump(svc_manifest, default_flow_style=False))
|
|
121
|
+
files.append(svc_path)
|
|
122
|
+
|
|
123
|
+
hpa_manifest = self._hpa_manifest(cfg)
|
|
124
|
+
hpa_path = k8s_dir / "hpa.yaml"
|
|
125
|
+
hpa_path.write_text(yaml.dump(hpa_manifest, default_flow_style=False))
|
|
126
|
+
files.append(hpa_path)
|
|
127
|
+
|
|
128
|
+
if cfg.secrets:
|
|
129
|
+
secret_manifest = self._secret_placeholder(cfg)
|
|
130
|
+
sec_path = k8s_dir / "secret.yaml"
|
|
131
|
+
sec_path.write_text(yaml.dump(secret_manifest, default_flow_style=False))
|
|
132
|
+
files.append(sec_path)
|
|
133
|
+
|
|
134
|
+
kustomization = self._kustomization(cfg)
|
|
135
|
+
kust_path = k8s_dir / "kustomization.yaml"
|
|
136
|
+
kust_path.write_text(yaml.dump(kustomization, default_flow_style=False))
|
|
137
|
+
files.append(kust_path)
|
|
138
|
+
|
|
139
|
+
return BuildResult(
|
|
140
|
+
app_name=cfg.name,
|
|
141
|
+
target="kubernetes",
|
|
142
|
+
output_dir=str(out),
|
|
143
|
+
files=[str(f) for f in files],
|
|
144
|
+
image=self.image,
|
|
145
|
+
namespace=self.namespace,
|
|
146
|
+
next_steps=self._next_steps(cfg),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _generate_dockerfile(self, cfg, adapter) -> str:
|
|
150
|
+
extras = adapter.pip_extras()
|
|
151
|
+
pip_install = " ".join(extras) if extras else ""
|
|
152
|
+
env_lines = "\n".join(f'ENV {k}="{v}"' for k, v in cfg.env_vars.items())
|
|
153
|
+
# Healthcheck uses stdlib urllib so we don't need curl — keeps the
|
|
154
|
+
# image slim and avoids an apt-get layer + cache invalidation.
|
|
155
|
+
probe_url = f"http://localhost:{self.app._port}{self.app._health_path}"
|
|
156
|
+
healthcheck_probe = (
|
|
157
|
+
'python -c "import urllib.request,sys; '
|
|
158
|
+
f"sys.exit(0 if urllib.request.urlopen('{probe_url}',timeout=5)"
|
|
159
|
+
'.status==200 else 1)"'
|
|
160
|
+
)
|
|
161
|
+
return f"""FROM {self._base_image}
|
|
162
|
+
|
|
163
|
+
WORKDIR /app
|
|
164
|
+
|
|
165
|
+
COPY requirements.txt* ./
|
|
166
|
+
RUN pip install --no-cache-dir fastapi uvicorn{(" " + pip_install) if pip_install else ""} \\
|
|
167
|
+
$(test -f requirements.txt && echo "-r requirements.txt" || echo "")
|
|
168
|
+
|
|
169
|
+
COPY . .
|
|
170
|
+
|
|
171
|
+
{env_lines}
|
|
172
|
+
|
|
173
|
+
EXPOSE {self.app._port}
|
|
174
|
+
|
|
175
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \\
|
|
176
|
+
CMD {healthcheck_probe} || exit 1
|
|
177
|
+
|
|
178
|
+
CMD ["python", "server.py"]
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def _deployment_manifest(self, cfg) -> dict:
|
|
182
|
+
full_image = f"{self.registry}/{self.image}" if self.registry else self.image
|
|
183
|
+
env_vars = [{"name": k, "value": v} for k, v in cfg.env_vars.items()]
|
|
184
|
+
for secret_name in cfg.secrets:
|
|
185
|
+
env_vars.append(
|
|
186
|
+
{
|
|
187
|
+
"name": secret_name.upper().replace("-", "_"),
|
|
188
|
+
"valueFrom": {
|
|
189
|
+
"secretKeyRef": {
|
|
190
|
+
"name": f"{cfg.name}-secrets",
|
|
191
|
+
"key": secret_name,
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
return {
|
|
197
|
+
"apiVersion": "apps/v1",
|
|
198
|
+
"kind": "Deployment",
|
|
199
|
+
"metadata": {
|
|
200
|
+
"name": cfg.name,
|
|
201
|
+
"namespace": self.namespace,
|
|
202
|
+
"labels": {"app": cfg.name, "version": cfg.version},
|
|
203
|
+
},
|
|
204
|
+
"spec": {
|
|
205
|
+
"replicas": self._replicas,
|
|
206
|
+
"selector": {"matchLabels": {"app": cfg.name}},
|
|
207
|
+
"template": {
|
|
208
|
+
"metadata": {"labels": {"app": cfg.name, "version": cfg.version}},
|
|
209
|
+
"spec": {
|
|
210
|
+
"containers": [
|
|
211
|
+
{
|
|
212
|
+
"name": cfg.name,
|
|
213
|
+
"image": full_image,
|
|
214
|
+
"imagePullPolicy": "Always",
|
|
215
|
+
"ports": [{"containerPort": self.app._port}],
|
|
216
|
+
"env": env_vars,
|
|
217
|
+
"resources": {
|
|
218
|
+
"requests": {
|
|
219
|
+
"memory": f"{cfg.memory_mb // 2}Mi",
|
|
220
|
+
"cpu": "250m",
|
|
221
|
+
},
|
|
222
|
+
"limits": {
|
|
223
|
+
"memory": f"{cfg.memory_mb}Mi",
|
|
224
|
+
"cpu": "1000m",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
"livenessProbe": {
|
|
228
|
+
"httpGet": {
|
|
229
|
+
"path": self.app._health_path,
|
|
230
|
+
"port": self.app._port,
|
|
231
|
+
},
|
|
232
|
+
"initialDelaySeconds": 15,
|
|
233
|
+
"periodSeconds": 30,
|
|
234
|
+
},
|
|
235
|
+
"readinessProbe": {
|
|
236
|
+
"httpGet": {
|
|
237
|
+
"path": self.app._health_path,
|
|
238
|
+
"port": self.app._port,
|
|
239
|
+
},
|
|
240
|
+
"initialDelaySeconds": 5,
|
|
241
|
+
"periodSeconds": 10,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
],
|
|
245
|
+
"terminationGracePeriodSeconds": cfg.timeout_seconds,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def _service_manifest(self, cfg) -> dict:
|
|
252
|
+
return {
|
|
253
|
+
"apiVersion": "v1",
|
|
254
|
+
"kind": "Service",
|
|
255
|
+
"metadata": {"name": cfg.name, "namespace": self.namespace},
|
|
256
|
+
"spec": {
|
|
257
|
+
"selector": {"app": cfg.name},
|
|
258
|
+
"ports": [{"port": 80, "targetPort": self.app._port, "protocol": "TCP"}],
|
|
259
|
+
"type": "ClusterIP",
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def _hpa_manifest(self, cfg) -> dict:
|
|
264
|
+
return {
|
|
265
|
+
"apiVersion": "autoscaling/v2",
|
|
266
|
+
"kind": "HorizontalPodAutoscaler",
|
|
267
|
+
"metadata": {"name": f"{cfg.name}-hpa", "namespace": self.namespace},
|
|
268
|
+
"spec": {
|
|
269
|
+
"scaleTargetRef": {
|
|
270
|
+
"apiVersion": "apps/v1",
|
|
271
|
+
"kind": "Deployment",
|
|
272
|
+
"name": cfg.name,
|
|
273
|
+
},
|
|
274
|
+
"minReplicas": self._autoscale_min,
|
|
275
|
+
"maxReplicas": self._autoscale_max,
|
|
276
|
+
"metrics": [
|
|
277
|
+
{
|
|
278
|
+
"type": "Resource",
|
|
279
|
+
"resource": {
|
|
280
|
+
"name": "cpu",
|
|
281
|
+
"target": {
|
|
282
|
+
"type": "Utilization",
|
|
283
|
+
"averageUtilization": self._autoscale_cpu_pct,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def _secret_placeholder(self, cfg) -> dict:
|
|
292
|
+
return {
|
|
293
|
+
"apiVersion": "v1",
|
|
294
|
+
"kind": "Secret",
|
|
295
|
+
"metadata": {
|
|
296
|
+
"name": f"{cfg.name}-secrets",
|
|
297
|
+
"namespace": self.namespace,
|
|
298
|
+
},
|
|
299
|
+
"type": "Opaque",
|
|
300
|
+
"stringData": {s: f"<FILL_IN_{s.upper()}>" for s in cfg.secrets},
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
def _kustomization(self, cfg) -> dict:
|
|
304
|
+
resources = ["deployment.yaml", "service.yaml", "hpa.yaml"]
|
|
305
|
+
if cfg.secrets:
|
|
306
|
+
resources.append("secret.yaml")
|
|
307
|
+
return {
|
|
308
|
+
"apiVersion": "kustomize.config.k8s.io/v1beta1",
|
|
309
|
+
"kind": "Kustomization",
|
|
310
|
+
"resources": resources,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
def _next_steps(self, cfg) -> list[str]:
|
|
314
|
+
full_image = f"{self.registry}/{self.image}" if self.registry else self.image
|
|
315
|
+
steps = [
|
|
316
|
+
f"1. docker build -t {full_image} ./deploy/{cfg.name}",
|
|
317
|
+
f"2. docker push {full_image}",
|
|
318
|
+
f"3. kubectl apply -k ./deploy/{cfg.name}/k8s/",
|
|
319
|
+
f"4. kubectl rollout status deployment/{cfg.name} -n {self.namespace}",
|
|
320
|
+
]
|
|
321
|
+
if cfg.secrets:
|
|
322
|
+
steps.insert(2, f" (fill in ./deploy/{cfg.name}/k8s/secret.yaml before applying)")
|
|
323
|
+
return steps
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class BuildResult:
|
|
328
|
+
app_name: str
|
|
329
|
+
target: str
|
|
330
|
+
output_dir: str
|
|
331
|
+
files: list[str]
|
|
332
|
+
image: str
|
|
333
|
+
namespace: str
|
|
334
|
+
next_steps: list[str]
|
|
335
|
+
|
|
336
|
+
def __repr__(self) -> str:
|
|
337
|
+
files_str = "\n ".join(self.files)
|
|
338
|
+
steps_str = "\n ".join(self.next_steps)
|
|
339
|
+
return (
|
|
340
|
+
f"\nBuildResult for '{self.app_name}' -> {self.target}\n"
|
|
341
|
+
f"Output: {self.output_dir}\n"
|
|
342
|
+
f"Files:\n {files_str}\n"
|
|
343
|
+
f"Next steps:\n {steps_str}\n"
|
|
344
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LambdaTarget — generates a Lambda-compatible handler + SAM template.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from agentdeploy.core.app import AgentApp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class LambdaTarget:
|
|
17
|
+
app: AgentApp
|
|
18
|
+
region: str = "us-east-1"
|
|
19
|
+
function_name: str = ""
|
|
20
|
+
role_arn: str = ""
|
|
21
|
+
|
|
22
|
+
_memory_mb: int = field(default=0, init=False)
|
|
23
|
+
_timeout_seconds: int = field(default=0, init=False)
|
|
24
|
+
_output_dir: str = field(default="./deploy", init=False)
|
|
25
|
+
|
|
26
|
+
def with_output_dir(self, path: str) -> LambdaTarget:
|
|
27
|
+
self._output_dir = path
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def build(self) -> LambdaResult:
|
|
31
|
+
# Fail fast at build time rather than shipping a placeholder ARN that
|
|
32
|
+
# turns into an obscure AWS deploy failure later.
|
|
33
|
+
if not self.role_arn:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"LambdaTarget requires an IAM role ARN. Pass it via "
|
|
36
|
+
"deploy(app).to_lambda(role_arn='arn:aws:iam::ACCOUNT:role/...'). "
|
|
37
|
+
"The role must allow lambda.amazonaws.com to assume it and "
|
|
38
|
+
"include AWSLambdaBasicExecutionRole at minimum."
|
|
39
|
+
)
|
|
40
|
+
if not self.role_arn.startswith("arn:aws:iam::"):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"role_arn must be a full IAM role ARN starting with "
|
|
43
|
+
f"'arn:aws:iam::ACCOUNT:role/...', got: {self.role_arn!r}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
cfg = self.app.to_config()
|
|
47
|
+
adapter = self.app._adapter
|
|
48
|
+
mem = self._memory_mb or cfg.memory_mb
|
|
49
|
+
timeout = self._timeout_seconds or min(cfg.timeout_seconds, 900)
|
|
50
|
+
|
|
51
|
+
out = Path(self._output_dir) / cfg.name
|
|
52
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
handler_code = self._handler_code(cfg, adapter)
|
|
55
|
+
(out / "handler.py").write_text(handler_code)
|
|
56
|
+
|
|
57
|
+
sam = self._sam_template(cfg, mem, timeout)
|
|
58
|
+
sam_path = out / "template.yaml"
|
|
59
|
+
sam_path.write_text(yaml.dump(sam, default_flow_style=False))
|
|
60
|
+
|
|
61
|
+
dockerfile = self._dockerfile(cfg, adapter)
|
|
62
|
+
(out / "Dockerfile").write_text(dockerfile)
|
|
63
|
+
|
|
64
|
+
return LambdaResult(
|
|
65
|
+
app_name=cfg.name,
|
|
66
|
+
output_dir=str(out),
|
|
67
|
+
next_steps=[
|
|
68
|
+
f"cd {out}",
|
|
69
|
+
"sam build",
|
|
70
|
+
f"sam deploy --region {self.region} --stack-name {self.function_name}",
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _handler_code(self, cfg, adapter) -> str:
|
|
75
|
+
extras = adapter.pip_extras()
|
|
76
|
+
return f"""
|
|
77
|
+
import json, os, asyncio
|
|
78
|
+
|
|
79
|
+
# Framework imports injected by adapter: {", ".join(extras)}
|
|
80
|
+
from agent import agent # your agent module
|
|
81
|
+
|
|
82
|
+
def handler(event, context):
|
|
83
|
+
\"\"\"AWS Lambda entrypoint. Accepts API Gateway or direct invocation.\"\"\"
|
|
84
|
+
body = event.get("body", event)
|
|
85
|
+
if isinstance(body, str):
|
|
86
|
+
body = json.loads(body)
|
|
87
|
+
|
|
88
|
+
user_input = body.get("input", body)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
result = asyncio.run(_invoke(user_input))
|
|
92
|
+
return {{
|
|
93
|
+
"statusCode": 200,
|
|
94
|
+
"headers": {{"Content-Type": "application/json"}},
|
|
95
|
+
"body": json.dumps({{"result": result}}),
|
|
96
|
+
}}
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return {{
|
|
99
|
+
"statusCode": 500,
|
|
100
|
+
"body": json.dumps({{"error": str(e)}}),
|
|
101
|
+
}}
|
|
102
|
+
|
|
103
|
+
async def _invoke(user_input):
|
|
104
|
+
if hasattr(agent, "ainvoke"):
|
|
105
|
+
return await agent.ainvoke(user_input)
|
|
106
|
+
elif hasattr(agent, "kickoff"):
|
|
107
|
+
return str(agent.kickoff(inputs=user_input))
|
|
108
|
+
elif callable(agent):
|
|
109
|
+
result = agent(user_input)
|
|
110
|
+
if asyncio.iscoroutine(result):
|
|
111
|
+
return await result
|
|
112
|
+
return result
|
|
113
|
+
raise RuntimeError("Agent has no invocable method.")
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def _sam_template(self, cfg, mem: int, timeout: int) -> dict:
|
|
117
|
+
env = {k: v for k, v in cfg.env_vars.items()}
|
|
118
|
+
return {
|
|
119
|
+
"AWSTemplateFormatVersion": "2010-09-09",
|
|
120
|
+
"Transform": "AWS::Serverless-2016-10-31",
|
|
121
|
+
"Description": cfg.description or cfg.name,
|
|
122
|
+
"Resources": {
|
|
123
|
+
cfg.name.replace("-", ""): {
|
|
124
|
+
"Type": "AWS::Serverless::Function",
|
|
125
|
+
"Properties": {
|
|
126
|
+
"FunctionName": self.function_name or cfg.name,
|
|
127
|
+
"Handler": "handler.handler",
|
|
128
|
+
"Runtime": "python3.11",
|
|
129
|
+
"MemorySize": mem,
|
|
130
|
+
"Timeout": timeout,
|
|
131
|
+
"Role": self.role_arn,
|
|
132
|
+
"Environment": {"Variables": env},
|
|
133
|
+
"Events": {
|
|
134
|
+
"Api": {
|
|
135
|
+
"Type": "Api",
|
|
136
|
+
"Properties": {
|
|
137
|
+
"Path": "/invoke",
|
|
138
|
+
"Method": "post",
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def _dockerfile(self, cfg, adapter) -> str:
|
|
148
|
+
extras = " ".join(adapter.pip_extras())
|
|
149
|
+
return f"""FROM public.ecr.aws/lambda/python:3.11
|
|
150
|
+
RUN pip install --no-cache-dir {extras or ""}
|
|
151
|
+
COPY . ${{LAMBDA_TASK_ROOT}}
|
|
152
|
+
CMD ["handler.handler"]
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class LambdaResult:
|
|
158
|
+
app_name: str
|
|
159
|
+
output_dir: str
|
|
160
|
+
next_steps: list[str]
|