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.
Files changed (25) hide show
  1. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/CHANGELOG.md +14 -1
  2. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/PKG-INFO +1 -1
  3. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/pyproject.toml +1 -1
  4. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/__init__.py +1 -1
  5. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/kubernetes.py +222 -34
  6. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/tests/test_core.py +130 -0
  7. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/.gitignore +0 -0
  8. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/LICENSE +0 -0
  9. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/README.md +0 -0
  10. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/examples/langgraph_example/main.py +0 -0
  11. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/adapters/__init__.py +0 -0
  12. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/adapters/base.py +0 -0
  13. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/adapters/registry.py +0 -0
  14. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/cli/__init__.py +0 -0
  15. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/cli/main.py +0 -0
  16. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/__init__.py +0 -0
  17. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/app.py +0 -0
  18. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/deploy.py +0 -0
  19. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/hitl.py +0 -0
  20. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/core/observability.py +0 -0
  21. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/__init__.py +0 -0
  22. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/cloud_run.py +0 -0
  23. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/docker_compose.py +0 -0
  24. {agentdeploy-0.1.1 → agentdeploy-0.2.0}/src/agentdeploy/targets/lambda_target.py +0 -0
  25. {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.1.1...HEAD
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.1.1
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.1.1"
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.1.1"
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.core.app import AgentApp
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(dockerfile)
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(server_code)
162
+ server_path.write_text(adapter.entrypoint_code(cfg.name, self.app._port))
111
163
  files.append(server_path)
112
164
 
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)
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(svc_manifest, default_flow_style=False))
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(hpa_manifest, default_flow_style=False))
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(secret_manifest, default_flow_style=False))
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(yaml.dump(kustomization, default_flow_style=False))
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 _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()]
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": cfg.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": self._replicas,
206
- "selector": {"matchLabels": {"app": cfg.name}},
326
+ "replicas": replicas,
327
+ "selector": {"matchLabels": selector_labels},
207
328
  "template": {
208
- "metadata": {"labels": {"app": cfg.name, "version": cfg.version}},
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 _service_manifest(self, cfg) -> dict:
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, cfg) -> dict:
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 _next_steps(self, cfg) -> list[str]:
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