agentdeploy 0.1.1__tar.gz → 0.2.0__tar.gz
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-0.1.1 → agentdeploy-0.2.0}/CHANGELOG.md +14 -1
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/PKG-INFO +1 -1
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/pyproject.toml +1 -1
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/__init__.py +1 -1
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/kubernetes.py +222 -34
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/tests/test_core.py +130 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/.gitignore +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/LICENSE +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/README.md +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/examples/langgraph_example/main.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/adapters/__init__.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/adapters/base.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/adapters/registry.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/cli/__init__.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/cli/main.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/__init__.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/app.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/deploy.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/hitl.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/observability.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/__init__.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/cloud_run.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/docker_compose.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/lambda_target.py +0 -0
- {agentdeploy-0.1.1 → agentdeploy-0.2.0}/tests/__init__.py +0 -0
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-07-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `KubernetesTarget.with_canary()` — progressive canary rollout support with
|
|
14
|
+
two strategies:
|
|
15
|
+
- `native`: two Deployments with disjoint `track` labels, traffic split by
|
|
16
|
+
replica ratio. No service mesh required.
|
|
17
|
+
- `istio`: exact percentage-based traffic splitting via `DestinationRule` +
|
|
18
|
+
`VirtualService`. Generates `destination-rule.yaml` and `virtual-service.yaml`.
|
|
19
|
+
- `BuildResult.next_steps` now includes explicit promote and rollback commands
|
|
20
|
+
when a canary is configured.
|
|
21
|
+
|
|
10
22
|
## [0.1.1] - 2026-07-28
|
|
11
23
|
|
|
12
24
|
### Changed
|
|
@@ -42,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
42
54
|
- Test suite covering core wrappers, Kubernetes target, Docker Compose
|
|
43
55
|
target, HITL gate, and telemetry.
|
|
44
56
|
|
|
45
|
-
[Unreleased]: https://github.com/erenat77/agentdeploy/compare/v0.
|
|
57
|
+
[Unreleased]: https://github.com/erenat77/agentdeploy/compare/v0.2.0...HEAD
|
|
58
|
+
[0.2.0]: https://github.com/erenat77/agentdeploy/compare/v0.1.1...v0.2.0
|
|
46
59
|
[0.1.1]: https://github.com/erenat77/agentdeploy/compare/v0.1.0...v0.1.1
|
|
47
60
|
[0.1.0]: https://github.com/erenat77/agentdeploy/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentdeploy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Zero-boilerplate deployment for AI agent frameworks — LangGraph, CrewAI, OpenAI Agents SDK, and custom agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/erenat77/agentdeploy
|
|
6
6
|
Project-URL: Repository, https://github.com/erenat77/agentdeploy
|
|
@@ -57,7 +57,7 @@ build-backend = "hatchling.build"
|
|
|
57
57
|
|
|
58
58
|
[project]
|
|
59
59
|
name = "agentdeploy"
|
|
60
|
-
version = "0.
|
|
60
|
+
version = "0.2.0"
|
|
61
61
|
description = "Zero-boilerplate deployment for AI agent frameworks — LangGraph, CrewAI, OpenAI Agents SDK, and custom agents"
|
|
62
62
|
readme = "README.md"
|
|
63
63
|
requires-python = ">=3.11"
|
|
@@ -19,7 +19,7 @@ from agentdeploy.core.deploy import deploy
|
|
|
19
19
|
from agentdeploy.core.hitl import CheckpointResult, HITLDecision, HITLGate
|
|
20
20
|
from agentdeploy.core.observability import Telemetry
|
|
21
21
|
|
|
22
|
-
__version__ = "0.
|
|
22
|
+
__version__ = "0.2.0"
|
|
23
23
|
|
|
24
24
|
__all__ = [
|
|
25
25
|
"AgentApp",
|
|
@@ -2,26 +2,44 @@
|
|
|
2
2
|
KubernetesTarget — generates production-ready Kubernetes manifests
|
|
3
3
|
and a Dockerfile from an AgentApp.
|
|
4
4
|
|
|
5
|
-
Outputs:
|
|
5
|
+
Outputs (standard):
|
|
6
6
|
./deploy/<name>/Dockerfile
|
|
7
7
|
./deploy/<name>/k8s/deployment.yaml
|
|
8
8
|
./deploy/<name>/k8s/service.yaml
|
|
9
9
|
./deploy/<name>/k8s/hpa.yaml
|
|
10
10
|
./deploy/<name>/k8s/secret.yaml (if secrets declared)
|
|
11
|
+
./deploy/<name>/k8s/kustomization.yaml
|
|
12
|
+
|
|
13
|
+
Outputs (canary, native strategy):
|
|
14
|
+
./deploy/<name>/k8s/deployment-stable.yaml
|
|
15
|
+
./deploy/<name>/k8s/deployment-canary.yaml
|
|
16
|
+
(+ service, hpa, kustomization as above)
|
|
17
|
+
|
|
18
|
+
Outputs (canary, istio strategy):
|
|
19
|
+
(+ destination-rule.yaml, virtual-service.yaml)
|
|
11
20
|
"""
|
|
12
21
|
|
|
13
22
|
from __future__ import annotations
|
|
14
23
|
|
|
15
24
|
from dataclasses import dataclass, field
|
|
16
25
|
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
17
27
|
|
|
18
28
|
import yaml
|
|
19
29
|
|
|
20
|
-
from agentdeploy.
|
|
30
|
+
from agentdeploy.adapters.base import AgentAdapter
|
|
31
|
+
from agentdeploy.core.app import AgentApp, AgentConfig
|
|
21
32
|
from agentdeploy.core.hitl import HITLConfig
|
|
22
33
|
from agentdeploy.core.observability import OtelConfig
|
|
23
34
|
|
|
24
35
|
|
|
36
|
+
@dataclass
|
|
37
|
+
class CanaryConfig:
|
|
38
|
+
stable_image: str
|
|
39
|
+
percent: int
|
|
40
|
+
strategy: str = "native"
|
|
41
|
+
|
|
42
|
+
|
|
25
43
|
@dataclass
|
|
26
44
|
class KubernetesTarget:
|
|
27
45
|
app: AgentApp
|
|
@@ -38,6 +56,7 @@ class KubernetesTarget:
|
|
|
38
56
|
_output_dir: str = field(default="./deploy", init=False)
|
|
39
57
|
_python_version: str = field(default="3.11", init=False)
|
|
40
58
|
_base_image: str = field(default="python:3.11-slim", init=False)
|
|
59
|
+
_canary: CanaryConfig | None = field(default=None, init=False)
|
|
41
60
|
|
|
42
61
|
def with_replicas(self, count: int) -> KubernetesTarget:
|
|
43
62
|
self._replicas = count
|
|
@@ -89,51 +108,137 @@ class KubernetesTarget:
|
|
|
89
108
|
self._base_image = image
|
|
90
109
|
return self
|
|
91
110
|
|
|
111
|
+
def with_canary(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
percent: int,
|
|
115
|
+
stable_image: str,
|
|
116
|
+
strategy: str = "native",
|
|
117
|
+
) -> KubernetesTarget:
|
|
118
|
+
"""Enable canary rollout for this deployment.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
percent: Percentage of traffic (or replica share) to route to the
|
|
122
|
+
canary. Must be between 1 and 99 exclusive.
|
|
123
|
+
stable_image: The currently-running image that will serve stable
|
|
124
|
+
traffic. The new image (self.image) becomes the canary.
|
|
125
|
+
strategy: "native" (replica-ratio split, no service mesh) or
|
|
126
|
+
"istio" (exact weight split via VirtualService).
|
|
127
|
+
"""
|
|
128
|
+
if not stable_image:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"stable_image is required — it is the currently-running image "
|
|
131
|
+
"that will continue to serve stable traffic."
|
|
132
|
+
)
|
|
133
|
+
if not isinstance(percent, (int, float)) or not (0 < int(percent) < 100):
|
|
134
|
+
raise ValueError(f"percent must be between 1 and 99 (exclusive), got {percent!r}.")
|
|
135
|
+
if strategy not in ("native", "istio"):
|
|
136
|
+
raise ValueError(f"strategy must be 'native' or 'istio', got {strategy!r}.")
|
|
137
|
+
self._canary = CanaryConfig(
|
|
138
|
+
stable_image=stable_image,
|
|
139
|
+
percent=int(percent),
|
|
140
|
+
strategy=strategy,
|
|
141
|
+
)
|
|
142
|
+
return self
|
|
143
|
+
|
|
92
144
|
def build(self) -> BuildResult:
|
|
93
145
|
"""Generate all deployment artifacts and write them to disk."""
|
|
94
146
|
cfg = self.app.to_config()
|
|
95
147
|
adapter = self.app._adapter
|
|
148
|
+
assert adapter is not None, "AgentApp has no wrapped agent — call .wrap() first"
|
|
96
149
|
out = Path(self._output_dir) / cfg.name
|
|
97
150
|
k8s_dir = out / "k8s"
|
|
98
151
|
out.mkdir(parents=True, exist_ok=True)
|
|
99
152
|
k8s_dir.mkdir(parents=True, exist_ok=True)
|
|
100
153
|
|
|
101
154
|
files: list[Path] = []
|
|
155
|
+
k8s_resources: list[str] = []
|
|
102
156
|
|
|
103
|
-
dockerfile = self._generate_dockerfile(cfg, adapter)
|
|
104
157
|
df_path = out / "Dockerfile"
|
|
105
|
-
df_path.write_text(
|
|
158
|
+
df_path.write_text(self._generate_dockerfile(cfg, adapter))
|
|
106
159
|
files.append(df_path)
|
|
107
160
|
|
|
108
|
-
server_code = adapter.entrypoint_code(cfg.name, self.app._port)
|
|
109
161
|
server_path = out / "server.py"
|
|
110
|
-
server_path.write_text(
|
|
162
|
+
server_path.write_text(adapter.entrypoint_code(cfg.name, self.app._port))
|
|
111
163
|
files.append(server_path)
|
|
112
164
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
if self._canary:
|
|
166
|
+
canary_replicas = max(1, round(self._replicas * self._canary.percent / 100))
|
|
167
|
+
stable_replicas = max(1, self._replicas - canary_replicas)
|
|
168
|
+
|
|
169
|
+
stable_path = k8s_dir / "deployment-stable.yaml"
|
|
170
|
+
stable_path.write_text(
|
|
171
|
+
yaml.dump(
|
|
172
|
+
self._build_deployment_dict(
|
|
173
|
+
cfg,
|
|
174
|
+
name=cfg.name,
|
|
175
|
+
image=self._canary.stable_image,
|
|
176
|
+
replicas=stable_replicas,
|
|
177
|
+
track="stable",
|
|
178
|
+
),
|
|
179
|
+
default_flow_style=False,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
files.append(stable_path)
|
|
183
|
+
k8s_resources.append("deployment-stable.yaml")
|
|
184
|
+
|
|
185
|
+
canary_path = k8s_dir / "deployment-canary.yaml"
|
|
186
|
+
canary_path.write_text(
|
|
187
|
+
yaml.dump(
|
|
188
|
+
self._build_deployment_dict(
|
|
189
|
+
cfg,
|
|
190
|
+
name=f"{cfg.name}-canary",
|
|
191
|
+
image=self.image,
|
|
192
|
+
replicas=canary_replicas,
|
|
193
|
+
track="canary",
|
|
194
|
+
),
|
|
195
|
+
default_flow_style=False,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
files.append(canary_path)
|
|
199
|
+
k8s_resources.append("deployment-canary.yaml")
|
|
200
|
+
|
|
201
|
+
if self._canary.strategy == "istio":
|
|
202
|
+
dr_path = k8s_dir / "destination-rule.yaml"
|
|
203
|
+
dr_path.write_text(
|
|
204
|
+
yaml.dump(self._istio_destination_rule(cfg), default_flow_style=False)
|
|
205
|
+
)
|
|
206
|
+
files.append(dr_path)
|
|
207
|
+
|
|
208
|
+
vs_path = k8s_dir / "virtual-service.yaml"
|
|
209
|
+
vs_path.write_text(
|
|
210
|
+
yaml.dump(self._istio_virtual_service(cfg), default_flow_style=False)
|
|
211
|
+
)
|
|
212
|
+
files.append(vs_path)
|
|
213
|
+
else:
|
|
214
|
+
dep_path = k8s_dir / "deployment.yaml"
|
|
215
|
+
dep_path.write_text(yaml.dump(self._deployment_manifest(cfg), default_flow_style=False))
|
|
216
|
+
files.append(dep_path)
|
|
217
|
+
k8s_resources.append("deployment.yaml")
|
|
117
218
|
|
|
118
|
-
svc_manifest = self._service_manifest(cfg)
|
|
119
219
|
svc_path = k8s_dir / "service.yaml"
|
|
120
|
-
svc_path.write_text(yaml.dump(
|
|
220
|
+
svc_path.write_text(yaml.dump(self._service_manifest(cfg), default_flow_style=False))
|
|
121
221
|
files.append(svc_path)
|
|
222
|
+
k8s_resources.append("service.yaml")
|
|
122
223
|
|
|
123
|
-
hpa_manifest = self._hpa_manifest(cfg)
|
|
124
224
|
hpa_path = k8s_dir / "hpa.yaml"
|
|
125
|
-
hpa_path.write_text(yaml.dump(
|
|
225
|
+
hpa_path.write_text(yaml.dump(self._hpa_manifest(cfg), default_flow_style=False))
|
|
126
226
|
files.append(hpa_path)
|
|
227
|
+
k8s_resources.append("hpa.yaml")
|
|
127
228
|
|
|
128
229
|
if cfg.secrets:
|
|
129
|
-
secret_manifest = self._secret_placeholder(cfg)
|
|
130
230
|
sec_path = k8s_dir / "secret.yaml"
|
|
131
|
-
sec_path.write_text(yaml.dump(
|
|
231
|
+
sec_path.write_text(yaml.dump(self._secret_placeholder(cfg), default_flow_style=False))
|
|
132
232
|
files.append(sec_path)
|
|
233
|
+
k8s_resources.append("secret.yaml")
|
|
234
|
+
|
|
235
|
+
if self._canary and self._canary.strategy == "istio":
|
|
236
|
+
k8s_resources.extend(["destination-rule.yaml", "virtual-service.yaml"])
|
|
133
237
|
|
|
134
|
-
kustomization = self._kustomization(cfg)
|
|
135
238
|
kust_path = k8s_dir / "kustomization.yaml"
|
|
136
|
-
kust_path.write_text(
|
|
239
|
+
kust_path.write_text(
|
|
240
|
+
yaml.dump(self._kustomization(k8s_resources), default_flow_style=False)
|
|
241
|
+
)
|
|
137
242
|
files.append(kust_path)
|
|
138
243
|
|
|
139
244
|
return BuildResult(
|
|
@@ -146,7 +251,7 @@ class KubernetesTarget:
|
|
|
146
251
|
next_steps=self._next_steps(cfg),
|
|
147
252
|
)
|
|
148
253
|
|
|
149
|
-
def _generate_dockerfile(self, cfg, adapter) -> str:
|
|
254
|
+
def _generate_dockerfile(self, cfg: AgentConfig, adapter: AgentAdapter) -> str:
|
|
150
255
|
extras = adapter.pip_extras()
|
|
151
256
|
pip_install = " ".join(extras) if extras else ""
|
|
152
257
|
env_lines = "\n".join(f'ENV {k}="{v}"' for k, v in cfg.env_vars.items())
|
|
@@ -178,9 +283,18 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \\
|
|
|
178
283
|
CMD ["python", "server.py"]
|
|
179
284
|
"""
|
|
180
285
|
|
|
181
|
-
def
|
|
182
|
-
|
|
183
|
-
|
|
286
|
+
def _build_deployment_dict(
|
|
287
|
+
self,
|
|
288
|
+
cfg: AgentConfig,
|
|
289
|
+
*,
|
|
290
|
+
name: str,
|
|
291
|
+
image: str,
|
|
292
|
+
replicas: int,
|
|
293
|
+
track: str | None = None,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""Core deployment manifest builder shared by standard and canary paths."""
|
|
296
|
+
full_image = f"{self.registry}/{image}" if self.registry else image
|
|
297
|
+
env_vars: list[dict[str, Any]] = [{"name": k, "value": v} for k, v in cfg.env_vars.items()]
|
|
184
298
|
for secret_name in cfg.secrets:
|
|
185
299
|
env_vars.append(
|
|
186
300
|
{
|
|
@@ -193,19 +307,26 @@ CMD ["python", "server.py"]
|
|
|
193
307
|
},
|
|
194
308
|
}
|
|
195
309
|
)
|
|
310
|
+
|
|
311
|
+
pod_labels: dict[str, str] = {"app": cfg.name, "version": cfg.version}
|
|
312
|
+
selector_labels: dict[str, str] = {"app": cfg.name}
|
|
313
|
+
if track:
|
|
314
|
+
pod_labels["track"] = track
|
|
315
|
+
selector_labels["track"] = track
|
|
316
|
+
|
|
196
317
|
return {
|
|
197
318
|
"apiVersion": "apps/v1",
|
|
198
319
|
"kind": "Deployment",
|
|
199
320
|
"metadata": {
|
|
200
|
-
"name":
|
|
321
|
+
"name": name,
|
|
201
322
|
"namespace": self.namespace,
|
|
202
323
|
"labels": {"app": cfg.name, "version": cfg.version},
|
|
203
324
|
},
|
|
204
325
|
"spec": {
|
|
205
|
-
"replicas":
|
|
206
|
-
"selector": {"matchLabels":
|
|
326
|
+
"replicas": replicas,
|
|
327
|
+
"selector": {"matchLabels": selector_labels},
|
|
207
328
|
"template": {
|
|
208
|
-
"metadata": {"labels":
|
|
329
|
+
"metadata": {"labels": pod_labels},
|
|
209
330
|
"spec": {
|
|
210
331
|
"containers": [
|
|
211
332
|
{
|
|
@@ -248,7 +369,12 @@ CMD ["python", "server.py"]
|
|
|
248
369
|
},
|
|
249
370
|
}
|
|
250
371
|
|
|
251
|
-
def
|
|
372
|
+
def _deployment_manifest(self, cfg: AgentConfig) -> dict[str, Any]:
|
|
373
|
+
return self._build_deployment_dict(
|
|
374
|
+
cfg, name=cfg.name, image=self.image, replicas=self._replicas
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _service_manifest(self, cfg: AgentConfig) -> dict[str, Any]:
|
|
252
378
|
return {
|
|
253
379
|
"apiVersion": "v1",
|
|
254
380
|
"kind": "Service",
|
|
@@ -260,7 +386,7 @@ CMD ["python", "server.py"]
|
|
|
260
386
|
},
|
|
261
387
|
}
|
|
262
388
|
|
|
263
|
-
def _hpa_manifest(self, cfg) -> dict:
|
|
389
|
+
def _hpa_manifest(self, cfg: AgentConfig) -> dict[str, Any]:
|
|
264
390
|
return {
|
|
265
391
|
"apiVersion": "autoscaling/v2",
|
|
266
392
|
"kind": "HorizontalPodAutoscaler",
|
|
@@ -288,7 +414,7 @@ CMD ["python", "server.py"]
|
|
|
288
414
|
},
|
|
289
415
|
}
|
|
290
416
|
|
|
291
|
-
def _secret_placeholder(self, cfg) -> dict:
|
|
417
|
+
def _secret_placeholder(self, cfg: AgentConfig) -> dict[str, Any]:
|
|
292
418
|
return {
|
|
293
419
|
"apiVersion": "v1",
|
|
294
420
|
"kind": "Secret",
|
|
@@ -300,18 +426,80 @@ CMD ["python", "server.py"]
|
|
|
300
426
|
"stringData": {s: f"<FILL_IN_{s.upper()}>" for s in cfg.secrets},
|
|
301
427
|
}
|
|
302
428
|
|
|
303
|
-
def _kustomization(self,
|
|
304
|
-
resources = ["deployment.yaml", "service.yaml", "hpa.yaml"]
|
|
305
|
-
if cfg.secrets:
|
|
306
|
-
resources.append("secret.yaml")
|
|
429
|
+
def _kustomization(self, resources: list[str]) -> dict[str, Any]:
|
|
307
430
|
return {
|
|
308
431
|
"apiVersion": "kustomize.config.k8s.io/v1beta1",
|
|
309
432
|
"kind": "Kustomization",
|
|
310
433
|
"resources": resources,
|
|
311
434
|
}
|
|
312
435
|
|
|
313
|
-
def
|
|
436
|
+
def _istio_destination_rule(self, cfg: AgentConfig) -> dict[str, Any]:
|
|
437
|
+
return {
|
|
438
|
+
"apiVersion": "networking.istio.io/v1alpha1",
|
|
439
|
+
"kind": "DestinationRule",
|
|
440
|
+
"metadata": {"name": cfg.name, "namespace": self.namespace},
|
|
441
|
+
"spec": {
|
|
442
|
+
"host": cfg.name,
|
|
443
|
+
"subsets": [
|
|
444
|
+
{"name": "stable", "labels": {"track": "stable"}},
|
|
445
|
+
{"name": "canary", "labels": {"track": "canary"}},
|
|
446
|
+
],
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
def _istio_virtual_service(self, cfg: AgentConfig) -> dict[str, Any]:
|
|
451
|
+
assert self._canary is not None
|
|
452
|
+
stable_weight = 100 - self._canary.percent
|
|
453
|
+
canary_weight = self._canary.percent
|
|
454
|
+
return {
|
|
455
|
+
"apiVersion": "networking.istio.io/v1alpha1",
|
|
456
|
+
"kind": "VirtualService",
|
|
457
|
+
"metadata": {"name": cfg.name, "namespace": self.namespace},
|
|
458
|
+
"spec": {
|
|
459
|
+
"hosts": [cfg.name],
|
|
460
|
+
"http": [
|
|
461
|
+
{
|
|
462
|
+
"route": [
|
|
463
|
+
{
|
|
464
|
+
"destination": {"host": cfg.name, "subset": "stable"},
|
|
465
|
+
"weight": stable_weight,
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
"destination": {"host": cfg.name, "subset": "canary"},
|
|
469
|
+
"weight": canary_weight,
|
|
470
|
+
},
|
|
471
|
+
]
|
|
472
|
+
}
|
|
473
|
+
],
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
def _next_steps(self, cfg: AgentConfig) -> list[str]:
|
|
314
478
|
full_image = f"{self.registry}/{self.image}" if self.registry else self.image
|
|
479
|
+
|
|
480
|
+
if self._canary:
|
|
481
|
+
steps = [
|
|
482
|
+
f"1. docker build -t {full_image} ./deploy/{cfg.name}",
|
|
483
|
+
f"2. docker push {full_image}",
|
|
484
|
+
f"3. kubectl apply -k ./deploy/{cfg.name}/k8s/",
|
|
485
|
+
f"4. kubectl rollout status deployment/{cfg.name} -n {self.namespace}",
|
|
486
|
+
f"5. kubectl rollout status deployment/{cfg.name}-canary -n {self.namespace}",
|
|
487
|
+
f"6. # Monitor: kubectl get pods -l app={cfg.name},track=canary -n {self.namespace}",
|
|
488
|
+
]
|
|
489
|
+
if self._canary.strategy == "istio":
|
|
490
|
+
steps += [
|
|
491
|
+
f"7. # Promote: set canary weight to 100 in virtual-service.yaml → kubectl apply -k ./deploy/{cfg.name}/k8s/",
|
|
492
|
+
f"8. # Rollback: set canary weight to 0 in virtual-service.yaml → kubectl apply -k ./deploy/{cfg.name}/k8s/",
|
|
493
|
+
]
|
|
494
|
+
else:
|
|
495
|
+
steps += [
|
|
496
|
+
f"7. # Promote: kubectl scale deployment/{cfg.name}-canary --replicas={self._replicas} -n {self.namespace} && kubectl delete deployment/{cfg.name} -n {self.namespace}",
|
|
497
|
+
f"8. # Rollback: kubectl delete deployment/{cfg.name}-canary -n {self.namespace}",
|
|
498
|
+
]
|
|
499
|
+
if cfg.secrets:
|
|
500
|
+
steps.insert(2, f" (fill in ./deploy/{cfg.name}/k8s/secret.yaml before applying)")
|
|
501
|
+
return steps
|
|
502
|
+
|
|
315
503
|
steps = [
|
|
316
504
|
f"1. docker build -t {full_image} ./deploy/{cfg.name}",
|
|
317
505
|
f"2. docker push {full_image}",
|
|
@@ -306,6 +306,136 @@ class TestAdapterDetection:
|
|
|
306
306
|
assert adapter.framework_name == "openai-agents"
|
|
307
307
|
|
|
308
308
|
|
|
309
|
+
class TestCanaryRollout:
|
|
310
|
+
def _make_app(self, name: str = "canary-agent"):
|
|
311
|
+
from agentdeploy import AgentApp
|
|
312
|
+
|
|
313
|
+
return AgentApp(name).wrap(_make_langgraph()).resources(memory_mb=1024)
|
|
314
|
+
|
|
315
|
+
def test_canary_generates_two_deployments_not_one(self, tmp_path):
|
|
316
|
+
from agentdeploy import deploy
|
|
317
|
+
|
|
318
|
+
(
|
|
319
|
+
deploy(self._make_app())
|
|
320
|
+
.to_kubernetes(image="my-agent:0.2.0")
|
|
321
|
+
.with_replicas(10)
|
|
322
|
+
.with_canary(percent=10, stable_image="my-agent:0.1.0")
|
|
323
|
+
.with_output_dir(str(tmp_path))
|
|
324
|
+
.build()
|
|
325
|
+
)
|
|
326
|
+
k8s = tmp_path / "canary-agent" / "k8s"
|
|
327
|
+
assert (k8s / "deployment-stable.yaml").exists()
|
|
328
|
+
assert (k8s / "deployment-canary.yaml").exists()
|
|
329
|
+
assert not (k8s / "deployment.yaml").exists()
|
|
330
|
+
|
|
331
|
+
def test_native_replica_split(self, tmp_path):
|
|
332
|
+
import yaml
|
|
333
|
+
|
|
334
|
+
from agentdeploy import deploy
|
|
335
|
+
|
|
336
|
+
(
|
|
337
|
+
deploy(self._make_app())
|
|
338
|
+
.to_kubernetes(image="my-agent:0.2.0")
|
|
339
|
+
.with_replicas(10)
|
|
340
|
+
.with_canary(percent=10, stable_image="my-agent:0.1.0")
|
|
341
|
+
.with_output_dir(str(tmp_path))
|
|
342
|
+
.build()
|
|
343
|
+
)
|
|
344
|
+
k8s = tmp_path / "canary-agent" / "k8s"
|
|
345
|
+
stable = yaml.safe_load((k8s / "deployment-stable.yaml").read_text())
|
|
346
|
+
canary = yaml.safe_load((k8s / "deployment-canary.yaml").read_text())
|
|
347
|
+
assert stable["spec"]["replicas"] == 9
|
|
348
|
+
assert canary["spec"]["replicas"] == 1
|
|
349
|
+
|
|
350
|
+
def test_deployment_selectors_are_disjoint_and_service_is_broad(self, tmp_path):
|
|
351
|
+
import yaml
|
|
352
|
+
|
|
353
|
+
from agentdeploy import deploy
|
|
354
|
+
|
|
355
|
+
(
|
|
356
|
+
deploy(self._make_app())
|
|
357
|
+
.to_kubernetes(image="my-agent:0.2.0")
|
|
358
|
+
.with_replicas(4)
|
|
359
|
+
.with_canary(percent=25, stable_image="my-agent:0.1.0")
|
|
360
|
+
.with_output_dir(str(tmp_path))
|
|
361
|
+
.build()
|
|
362
|
+
)
|
|
363
|
+
k8s = tmp_path / "canary-agent" / "k8s"
|
|
364
|
+
stable = yaml.safe_load((k8s / "deployment-stable.yaml").read_text())
|
|
365
|
+
canary = yaml.safe_load((k8s / "deployment-canary.yaml").read_text())
|
|
366
|
+
svc = yaml.safe_load((k8s / "service.yaml").read_text())
|
|
367
|
+
stable_sel = stable["spec"]["selector"]["matchLabels"]
|
|
368
|
+
canary_sel = canary["spec"]["selector"]["matchLabels"]
|
|
369
|
+
assert stable_sel != canary_sel
|
|
370
|
+
assert "track" in stable_sel
|
|
371
|
+
assert "track" in canary_sel
|
|
372
|
+
assert "track" not in svc["spec"]["selector"]
|
|
373
|
+
|
|
374
|
+
def test_istio_strategy_generates_mesh_files(self, tmp_path):
|
|
375
|
+
from agentdeploy import deploy
|
|
376
|
+
|
|
377
|
+
(
|
|
378
|
+
deploy(self._make_app())
|
|
379
|
+
.to_kubernetes(image="my-agent:0.2.0")
|
|
380
|
+
.with_replicas(4)
|
|
381
|
+
.with_canary(percent=20, stable_image="my-agent:0.1.0", strategy="istio")
|
|
382
|
+
.with_output_dir(str(tmp_path))
|
|
383
|
+
.build()
|
|
384
|
+
)
|
|
385
|
+
k8s = tmp_path / "canary-agent" / "k8s"
|
|
386
|
+
assert (k8s / "destination-rule.yaml").exists()
|
|
387
|
+
assert (k8s / "virtual-service.yaml").exists()
|
|
388
|
+
|
|
389
|
+
def test_native_strategy_does_not_generate_mesh_files(self, tmp_path):
|
|
390
|
+
from agentdeploy import deploy
|
|
391
|
+
|
|
392
|
+
(
|
|
393
|
+
deploy(self._make_app())
|
|
394
|
+
.to_kubernetes(image="my-agent:0.2.0")
|
|
395
|
+
.with_replicas(4)
|
|
396
|
+
.with_canary(percent=20, stable_image="my-agent:0.1.0", strategy="native")
|
|
397
|
+
.with_output_dir(str(tmp_path))
|
|
398
|
+
.build()
|
|
399
|
+
)
|
|
400
|
+
k8s = tmp_path / "canary-agent" / "k8s"
|
|
401
|
+
assert not (k8s / "destination-rule.yaml").exists()
|
|
402
|
+
assert not (k8s / "virtual-service.yaml").exists()
|
|
403
|
+
|
|
404
|
+
def test_with_canary_validation(self):
|
|
405
|
+
from agentdeploy import deploy
|
|
406
|
+
|
|
407
|
+
app = self._make_app()
|
|
408
|
+
with pytest.raises(ValueError, match="percent"):
|
|
409
|
+
deploy(app).to_kubernetes(image="img:new").with_canary(
|
|
410
|
+
percent=0, stable_image="img:old"
|
|
411
|
+
)
|
|
412
|
+
with pytest.raises(ValueError, match="percent"):
|
|
413
|
+
deploy(app).to_kubernetes(image="img:new").with_canary(
|
|
414
|
+
percent=100, stable_image="img:old"
|
|
415
|
+
)
|
|
416
|
+
with pytest.raises(ValueError, match="stable_image"):
|
|
417
|
+
deploy(app).to_kubernetes(image="img:new").with_canary(percent=10, stable_image="")
|
|
418
|
+
with pytest.raises(ValueError, match="strategy"):
|
|
419
|
+
deploy(app).to_kubernetes(image="img:new").with_canary(
|
|
420
|
+
percent=10, stable_image="img:old", strategy="linkerd"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def test_canary_next_steps_include_promote_and_rollback(self, tmp_path):
|
|
424
|
+
from agentdeploy import deploy
|
|
425
|
+
|
|
426
|
+
result = (
|
|
427
|
+
deploy(self._make_app())
|
|
428
|
+
.to_kubernetes(image="my-agent:0.2.0")
|
|
429
|
+
.with_replicas(10)
|
|
430
|
+
.with_canary(percent=10, stable_image="my-agent:0.1.0")
|
|
431
|
+
.with_output_dir(str(tmp_path))
|
|
432
|
+
.build()
|
|
433
|
+
)
|
|
434
|
+
steps_text = " ".join(result.next_steps).lower()
|
|
435
|
+
assert "promote" in steps_text
|
|
436
|
+
assert "rollback" in steps_text
|
|
437
|
+
|
|
438
|
+
|
|
309
439
|
class TestHITLGate:
|
|
310
440
|
@pytest.mark.asyncio
|
|
311
441
|
async def test_console_approve(self, monkeypatch):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|