1use crate::util_helpers::truncate_with_ellipsis;
8use async_trait::async_trait;
9use serde_json::json;
10use zeroclaw_api::tool::{Tool, ToolResult};
11use zeroclaw_config::schema::CloudOpsConfig;
12
13pub struct CloudOpsTool {
17 config: CloudOpsConfig,
18}
19
20impl CloudOpsTool {
21 pub fn new(config: CloudOpsConfig) -> Self {
22 Self { config }
23 }
24}
25
26#[async_trait]
27impl Tool for CloudOpsTool {
28 fn name(&self) -> &str {
29 "cloud_ops"
30 }
31
32 fn description(&self) -> &str {
33 "Cloud transformation advisory tool. Analyzes IaC plans, assesses migration paths, \
34 reviews costs, and checks architecture against Well-Architected Framework pillars. \
35 Read-only: does not create or modify cloud resources."
36 }
37
38 fn parameters_schema(&self) -> serde_json::Value {
39 json!({
40 "type": "object",
41 "properties": {
42 "action": {
43 "type": "string",
44 "enum": ["review_iac", "assess_migration", "cost_analysis", "architecture_review"],
45 "description": "The analysis action to perform."
46 },
47 "input": {
48 "type": "string",
49 "description": "For review_iac: IaC plan text or JSON content to analyze. For assess_migration: current architecture description text. For cost_analysis: billing data as CSV/JSON text. For architecture_review: architecture description text. Note: provide text content directly, not file paths."
50 },
51 "cloud": {
52 "type": "string",
53 "description": "Target cloud model_provider (aws, azure, gcp). Uses configured default if omitted."
54 }
55 },
56 "required": ["action", "input"]
57 })
58 }
59
60 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
61 let action = match args.get("action") {
62 Some(v) => v.as_str().ok_or_else(|| {
63 ::zeroclaw_log::record!(
64 WARN,
65 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
66 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
67 .with_attrs(::serde_json::json!({
68 "param": "action",
69 "value": v,
70 })),
71 "cloud_ops: action must be a string"
72 );
73 anyhow::Error::msg(format!("'action' must be a string, got: {}", v))
74 })?,
75 None => {
76 return Ok(ToolResult {
77 success: false,
78 output: String::new(),
79 error: Some("'action' parameter is required".into()),
80 });
81 }
82 };
83 let input = match args.get("input") {
84 Some(v) => v.as_str().ok_or_else(|| {
85 ::zeroclaw_log::record!(
86 WARN,
87 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
88 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
89 .with_attrs(::serde_json::json!({
90 "param": "input",
91 "value": v,
92 })),
93 "cloud_ops: input must be a string"
94 );
95 anyhow::Error::msg(format!("'input' must be a string, got: {}", v))
96 })?,
97 None => "",
98 };
99 let cloud = match args.get("cloud") {
100 Some(v) => v.as_str().ok_or_else(|| {
101 ::zeroclaw_log::record!(
102 WARN,
103 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
104 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
105 .with_attrs(::serde_json::json!({
106 "param": "cloud",
107 "value": v,
108 })),
109 "cloud_ops: cloud must be a string"
110 );
111 anyhow::Error::msg(format!("'cloud' must be a string, got: {}", v))
112 })?,
113 None => &self.config.default_cloud,
114 };
115
116 if input.is_empty() {
117 return Ok(ToolResult {
118 success: false,
119 output: String::new(),
120 error: Some("'input' parameter is required and cannot be empty".into()),
121 });
122 }
123
124 if !self.config.supported_clouds.contains(&cloud.to_string()) {
125 return Ok(ToolResult {
126 success: false,
127 output: String::new(),
128 error: Some(format!(
129 "Cloud model_provider '{}' is not in supported_clouds: {:?}",
130 cloud, self.config.supported_clouds
131 )),
132 });
133 }
134
135 match action {
136 "review_iac" => self.review_iac(input, cloud).await,
137 "assess_migration" => self.assess_migration(input, cloud).await,
138 "cost_analysis" => self.cost_analysis(input, cloud).await,
139 "architecture_review" => self.architecture_review(input, cloud).await,
140 _ => Ok(ToolResult {
141 success: false,
142 output: String::new(),
143 error: Some(format!(
144 "Unknown action '{}'. Valid: review_iac, assess_migration, cost_analysis, architecture_review",
145 action
146 )),
147 }),
148 }
149 }
150}
151
152#[allow(clippy::unused_async)]
153impl CloudOpsTool {
154 async fn review_iac(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
155 let mut findings = Vec::new();
156
157 let iac_type = detect_iac_type(input);
159
160 for finding in scan_iac_security(input) {
162 findings.push(finding);
163 }
164
165 for finding in scan_iac_best_practices(input, cloud) {
167 findings.push(finding);
168 }
169
170 for finding in scan_iac_cost(input, cloud, self.config.cost_threshold_monthly_usd) {
172 findings.push(finding);
173 }
174
175 let output = json!({
176 "iac_type": iac_type,
177 "cloud": cloud,
178 "findings_count": findings.len(),
179 "findings": findings,
180 "supported_iac_tools": self.config.iac_tools,
181 });
182
183 Ok(ToolResult {
184 success: true,
185 output: serde_json::to_string_pretty(&output)?,
186 error: None,
187 })
188 }
189
190 async fn assess_migration(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
191 let recommendations = assess_migration_recommendations(input, cloud);
192
193 let output = json!({
194 "cloud": cloud,
195 "source_description": truncate_with_ellipsis(input, 200),
196 "recommendations": recommendations,
197 });
198
199 Ok(ToolResult {
200 success: true,
201 output: serde_json::to_string_pretty(&output)?,
202 error: None,
203 })
204 }
205
206 async fn cost_analysis(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
207 let opportunities =
208 analyze_cost_opportunities(input, self.config.cost_threshold_monthly_usd);
209
210 let output = json!({
211 "cloud": cloud,
212 "threshold_usd": self.config.cost_threshold_monthly_usd,
213 "opportunities_count": opportunities.len(),
214 "opportunities": opportunities,
215 });
216
217 Ok(ToolResult {
218 success: true,
219 output: serde_json::to_string_pretty(&output)?,
220 error: None,
221 })
222 }
223
224 async fn architecture_review(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
225 let frameworks = &self.config.well_architected_frameworks;
226 let pillars = review_architecture_pillars(input, cloud, frameworks);
227
228 let output = json!({
229 "cloud": cloud,
230 "frameworks": frameworks,
231 "pillars": pillars,
232 });
233
234 Ok(ToolResult {
235 success: true,
236 output: serde_json::to_string_pretty(&output)?,
237 error: None,
238 })
239 }
240}
241
242fn detect_iac_type(input: &str) -> &'static str {
245 let lower = input.to_lowercase();
246 if lower.contains("resource \"") || lower.contains("terraform") || lower.contains(".tf") {
247 "terraform"
248 } else if lower.contains("awstemplatebody")
249 || lower.contains("cloudformation")
250 || lower.contains("aws::")
251 {
252 "cloudformation"
253 } else if lower.contains("pulumi") {
254 "pulumi"
255 } else {
256 "unknown"
257 }
258}
259
260fn scan_iac_security(input: &str) -> Vec<serde_json::Value> {
262 let lower = input.to_lowercase();
263 let mut findings = Vec::new();
264
265 let security_patterns: &[(&str, &str, &str)] = &[
266 (
267 "0.0.0.0/0",
268 "high",
269 "Unrestricted ingress (0.0.0.0/0) detected. Restrict CIDR ranges to known networks.",
270 ),
271 (
272 "::/0",
273 "high",
274 "Unrestricted IPv6 ingress (::/0) detected. Restrict CIDR ranges.",
275 ),
276 (
277 "public_access",
278 "medium",
279 "Public access setting detected. Verify this is intentional and necessary.",
280 ),
281 (
282 "publicly_accessible",
283 "medium",
284 "Resource marked as publicly accessible. Ensure this is required.",
285 ),
286 (
287 "encrypted = false",
288 "high",
289 "Encryption explicitly disabled. Enable encryption at rest.",
290 ),
291 (
292 "\"*\"",
293 "medium",
294 "Wildcard permission detected. Follow least-privilege principle.",
295 ),
296 (
297 "password",
298 "medium",
299 "Hardcoded password reference detected. Use secrets manager instead.",
300 ),
301 (
302 "access_key",
303 "high",
304 "Access key reference in IaC. Use IAM roles or secrets manager.",
305 ),
306 (
307 "secret_key",
308 "high",
309 "Secret key reference in IaC. Use IAM roles or secrets manager.",
310 ),
311 ];
312
313 for (pattern, severity, message) in security_patterns {
314 if lower.contains(pattern) {
315 findings.push(json!({
316 "category": "security",
317 "severity": severity,
318 "message": message,
319 }));
320 }
321 }
322
323 findings
324}
325
326fn scan_iac_best_practices(input: &str, cloud: &str) -> Vec<serde_json::Value> {
328 let lower = input.to_lowercase();
329 let mut findings = Vec::new();
330
331 if !lower.contains("tags") && !lower.contains("tag") {
333 findings.push(json!({
334 "category": "best_practice",
335 "severity": "low",
336 "message": "No resource tags detected. Add tags for cost allocation and resource management.",
337 }));
338 }
339
340 if lower.contains("s3") && !lower.contains("versioning") {
342 findings.push(json!({
343 "category": "best_practice",
344 "severity": "medium",
345 "message": "S3 bucket without versioning detected. Enable versioning for data protection.",
346 }));
347 }
348
349 if !lower.contains("logging") && !lower.contains("log_group") && !lower.contains("access_logs")
351 {
352 findings.push(json!({
353 "category": "best_practice",
354 "severity": "low",
355 "message": format!("No logging configuration detected for {}. Enable access logging.", cloud),
356 }));
357 }
358
359 if lower.contains("rds") && !lower.contains("backup_retention") {
361 findings.push(json!({
362 "category": "best_practice",
363 "severity": "medium",
364 "message": "RDS instance without backup retention configuration. Set backup_retention_period.",
365 }));
366 }
367
368 findings
369}
370
371fn scan_iac_cost(input: &str, cloud: &str, threshold: f64) -> Vec<serde_json::Value> {
377 let lower = input.to_lowercase();
378 let mut findings = Vec::new();
379
380 let expensive_patterns: &[(&str, &str, f64, bool)] = &[
382 (
383 "instance_type",
384 "Review instance sizing. Consider right-sizing or spot/preemptible instances.",
385 50.0,
386 false,
387 ),
388 (
389 "nat_gateway",
390 "NAT Gateway detected. These incur hourly + data transfer charges. Consider VPC endpoints for AWS services.",
391 45.0,
392 true,
393 ),
394 (
395 "elastic_ip",
396 "Elastic IP detected. Unused EIPs incur charges.",
397 5.0,
398 true,
399 ),
400 (
401 "load_balancer",
402 "Load balancer detected. Verify it is needed; consider ALB over NLB/CLB for cost.",
403 25.0,
404 true,
405 ),
406 ];
407
408 for (pattern, message, estimated_cost, aws_only) in expensive_patterns {
409 if *aws_only && cloud != "aws" {
410 continue;
411 }
412 if *estimated_cost < threshold {
413 continue;
414 }
415 if lower.contains(pattern) {
416 findings.push(json!({
417 "category": "cost",
418 "severity": "info",
419 "message": message,
420 "estimated_monthly_usd": estimated_cost,
421 }));
422 }
423 }
424
425 findings
426}
427
428fn assess_migration_recommendations(input: &str, cloud: &str) -> Vec<serde_json::Value> {
430 let lower = input.to_lowercase();
431 let mut recs = Vec::new();
432
433 let migration_patterns: &[(&str, &str, &str, &str)] = &[
434 (
435 "monolith",
436 "Decompose into microservices or modular containers.",
437 "high",
438 "Consider containerizing with ECS/EKS (AWS), AKS (Azure), or GKE (GCP).",
439 ),
440 (
441 "vm",
442 "Migrate VMs to containers or serverless where feasible.",
443 "medium",
444 "Evaluate lift-and-shift to managed container services.",
445 ),
446 (
447 "on-premises",
448 "Assess workloads for cloud readiness using 6 Rs framework (rehost, replatform, refactor, repurchase, retire, retain).",
449 "high",
450 "Start with rehost for quick migration, then optimize.",
451 ),
452 (
453 "database",
454 "Evaluate managed database services for reduced operational overhead.",
455 "medium",
456 &format!(
457 "Consider managed options: RDS/Aurora (AWS), Azure SQL (Azure), Cloud SQL (GCP) for {}.",
458 cloud
459 ),
460 ),
461 (
462 "batch",
463 "Consider serverless compute for batch workloads.",
464 "low",
465 "Evaluate Lambda (AWS), Azure Functions, or Cloud Functions for event-driven batch.",
466 ),
467 (
468 "queue",
469 "Evaluate managed message queue services.",
470 "low",
471 "Consider SQS/SNS (AWS), Service Bus (Azure), or Pub/Sub (GCP).",
472 ),
473 (
474 "storage",
475 "Evaluate tiered object storage for cost optimization.",
476 "medium",
477 "Use lifecycle policies for infrequent access data.",
478 ),
479 (
480 "legacy",
481 "Assess modernization path: replatform or refactor.",
482 "high",
483 "Legacy systems carry tech debt; prioritize incremental modernization.",
484 ),
485 ];
486
487 for (keyword, recommendation, effort, detail) in migration_patterns {
488 if lower.contains(keyword) {
489 recs.push(json!({
490 "trigger": keyword,
491 "recommendation": recommendation,
492 "effort_estimate": effort,
493 "detail": detail,
494 "target_cloud": cloud,
495 }));
496 }
497 }
498
499 if recs.is_empty() {
500 recs.push(json!({
501 "trigger": "general",
502 "recommendation": "Provide more detail about current architecture components for targeted recommendations.",
503 "effort_estimate": "unknown",
504 "detail": "Include details about compute, storage, networking, and data layers.",
505 "target_cloud": cloud,
506 }));
507 }
508
509 recs
510}
511
512fn analyze_cost_opportunities(input: &str, threshold: f64) -> Vec<serde_json::Value> {
514 let lower = input.to_lowercase();
515 let mut opportunities = Vec::new();
516
517 let cost_patterns: &[(&str, &str, &str)] = &[
519 (
520 "reserved",
521 "Review reserved instance utilization. Unused reservations waste budget.",
522 "high",
523 ),
524 (
525 "on-demand",
526 "On-demand instances detected. Evaluate savings plans or reserved instances for stable workloads.",
527 "high",
528 ),
529 (
530 "data transfer",
531 "Data transfer costs detected. Use VPC endpoints, CDN, or regional placement to reduce.",
532 "medium",
533 ),
534 (
535 "storage",
536 "Storage costs detected. Implement lifecycle policies and tiered storage.",
537 "medium",
538 ),
539 (
540 "idle",
541 "Idle resources detected. Identify and terminate unused resources.",
542 "high",
543 ),
544 (
545 "unattached",
546 "Unattached resources (volumes, IPs) detected. Clean up to reduce waste.",
547 "medium",
548 ),
549 (
550 "snapshot",
551 "Snapshot costs detected. Review retention policies and delete stale snapshots.",
552 "low",
553 ),
554 ];
555
556 for (pattern, suggestion, priority) in cost_patterns {
557 if lower.contains(pattern) {
558 opportunities.push(json!({
559 "pattern": pattern,
560 "suggestion": suggestion,
561 "priority": priority,
562 "threshold_usd": threshold,
563 }));
564 }
565 }
566
567 if opportunities.is_empty() {
568 opportunities.push(json!({
569 "pattern": "general",
570 "suggestion": "Provide billing CSV/JSON data with service and cost columns for detailed analysis.",
571 "priority": "info",
572 "threshold_usd": threshold,
573 }));
574 }
575
576 opportunities
577}
578
579fn review_architecture_pillars(
581 input: &str,
582 cloud: &str,
583 _frameworks: &[String],
584) -> Vec<serde_json::Value> {
585 let lower = input.to_lowercase();
586
587 let pillars = vec![
588 ("security", review_pillar_security(&lower, cloud)),
589 ("reliability", review_pillar_reliability(&lower, cloud)),
590 ("performance", review_pillar_performance(&lower, cloud)),
591 ("cost_optimization", review_pillar_cost(&lower, cloud)),
592 (
593 "operational_excellence",
594 review_pillar_operations(&lower, cloud),
595 ),
596 ];
597
598 pillars
599 .into_iter()
600 .map(|(name, findings)| {
601 json!({
602 "pillar": name,
603 "findings_count": findings.len(),
604 "findings": findings,
605 })
606 })
607 .collect()
608}
609
610fn review_pillar_security(input: &str, _cloud: &str) -> Vec<String> {
611 let mut findings = Vec::new();
612 if !input.contains("iam") && !input.contains("identity") {
613 findings.push(
614 "No IAM/identity layer described. Define identity and access management strategy."
615 .into(),
616 );
617 }
618 if !input.contains("encrypt") {
619 findings
620 .push("No encryption mentioned. Implement encryption at rest and in transit.".into());
621 }
622 if !input.contains("firewall") && !input.contains("waf") && !input.contains("security group") {
623 findings.push(
624 "No network security controls described. Add WAF, security groups, or firewall rules."
625 .into(),
626 );
627 }
628 if !input.contains("audit") && !input.contains("logging") {
629 findings.push(
630 "No audit logging described. Enable CloudTrail/Azure Monitor/Cloud Audit Logs.".into(),
631 );
632 }
633 findings
634}
635
636fn review_pillar_reliability(input: &str, _cloud: &str) -> Vec<String> {
637 let mut findings = Vec::new();
638 if !input.contains("multi-az") && !input.contains("multi-region") && !input.contains("redundan")
639 {
640 findings
641 .push("No redundancy described. Consider multi-AZ or multi-region deployment.".into());
642 }
643 if !input.contains("backup") {
644 findings.push("No backup strategy described. Define RPO/RTO and backup schedules.".into());
645 }
646 if !input.contains("auto-scal") && !input.contains("autoscal") {
647 findings.push(
648 "No auto-scaling described. Implement scaling policies for variable load.".into(),
649 );
650 }
651 if !input.contains("health check") && !input.contains("monitor") {
652 findings.push("No health monitoring described. Add health checks and alerting.".into());
653 }
654 findings
655}
656
657fn review_pillar_performance(input: &str, _cloud: &str) -> Vec<String> {
658 let mut findings = Vec::new();
659 if !input.contains("cache") && !input.contains("cdn") {
660 findings
661 .push("No caching layer described. Consider CDN and application-level caching.".into());
662 }
663 if !input.contains("load balanc") {
664 findings
665 .push("No load balancing described. Add load balancer for distributed traffic.".into());
666 }
667 if !input.contains("metric") && !input.contains("benchmark") {
668 findings.push(
669 "No performance metrics described. Define SLIs/SLOs and baseline benchmarks.".into(),
670 );
671 }
672 findings
673}
674
675fn review_pillar_cost(input: &str, _cloud: &str) -> Vec<String> {
676 let mut findings = Vec::new();
677 if !input.contains("budget") && !input.contains("cost") {
678 findings
679 .push("No cost controls described. Set budget alerts and cost allocation tags.".into());
680 }
681 if !input.contains("reserved") && !input.contains("savings plan") && !input.contains("spot") {
682 findings.push("No cost optimization strategy described. Evaluate RIs, savings plans, or spot instances.".into());
683 }
684 if !input.contains("rightsiz") && !input.contains("right-siz") {
685 findings.push(
686 "No right-sizing mentioned. Regularly review instance utilization and downsize.".into(),
687 );
688 }
689 findings
690}
691
692fn review_pillar_operations(input: &str, _cloud: &str) -> Vec<String> {
693 let mut findings = Vec::new();
694 if !input.contains("iac")
695 && !input.contains("terraform")
696 && !input.contains("infrastructure as code")
697 {
698 findings.push(
699 "No IaC mentioned. Manage all infrastructure as code for reproducibility.".into(),
700 );
701 }
702 if !input.contains("ci") && !input.contains("pipeline") && !input.contains("deploy") {
703 findings.push("No CI/CD described. Automate build, test, and deployment pipelines.".into());
704 }
705 if !input.contains("runbook") && !input.contains("incident") {
706 findings.push(
707 "No incident response described. Create runbooks and incident procedures.".into(),
708 );
709 }
710 findings
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 fn test_config() -> CloudOpsConfig {
718 CloudOpsConfig::default()
719 }
720
721 #[tokio::test]
722 async fn review_iac_detects_security_findings() {
723 let tool = CloudOpsTool::new(test_config());
724 let result = tool
725 .execute(json!({
726 "action": "review_iac",
727 "input": "resource \"aws_security_group\" \"open\" { ingress { cidr_blocks = [\"0.0.0.0/0\"] } }"
728 }))
729 .await
730 .unwrap();
731
732 assert!(result.success);
733 assert!(result.output.contains("Unrestricted ingress"));
734 assert!(result.output.contains("high"));
735 }
736
737 #[tokio::test]
738 async fn review_iac_detects_terraform_type() {
739 let tool = CloudOpsTool::new(test_config());
740 let result = tool
741 .execute(json!({
742 "action": "review_iac",
743 "input": "resource \"aws_instance\" \"test\" { instance_type = \"t3.micro\" tags = { Name = \"test\" } }"
744 }))
745 .await
746 .unwrap();
747
748 assert!(result.success);
749 assert!(result.output.contains("\"iac_type\": \"terraform\""));
750 }
751
752 #[tokio::test]
753 async fn review_iac_detects_encrypted_false() {
754 let tool = CloudOpsTool::new(test_config());
755 let result = tool
756 .execute(json!({
757 "action": "review_iac",
758 "input": "resource \"aws_ebs_volume\" \"vol\" { encrypted = false tags = {} }"
759 }))
760 .await
761 .unwrap();
762
763 assert!(result.success);
764 assert!(result.output.contains("Encryption explicitly disabled"));
765 }
766
767 #[tokio::test]
768 async fn cost_analysis_detects_on_demand() {
769 let tool = CloudOpsTool::new(test_config());
770 let result = tool
771 .execute(json!({
772 "action": "cost_analysis",
773 "input": "service,cost\nEC2 On-Demand,5000\nS3 Storage,200"
774 }))
775 .await
776 .unwrap();
777
778 assert!(result.success);
779 assert!(result.output.contains("on-demand"));
780 assert!(result.output.contains("storage"));
781 }
782
783 #[tokio::test]
784 async fn architecture_review_returns_all_pillars() {
785 let tool = CloudOpsTool::new(test_config());
786 let result = tool
787 .execute(json!({
788 "action": "architecture_review",
789 "input": "Web app with EC2, RDS, S3. No caching layer."
790 }))
791 .await
792 .unwrap();
793
794 assert!(result.success);
795 assert!(result.output.contains("security"));
796 assert!(result.output.contains("reliability"));
797 assert!(result.output.contains("performance"));
798 assert!(result.output.contains("cost_optimization"));
799 assert!(result.output.contains("operational_excellence"));
800 }
801
802 #[tokio::test]
803 async fn assess_migration_detects_monolith() {
804 let tool = CloudOpsTool::new(test_config());
805 let result = tool
806 .execute(json!({
807 "action": "assess_migration",
808 "input": "Legacy monolith application running on VMs with on-premises database."
809 }))
810 .await
811 .unwrap();
812
813 assert!(result.success);
814 assert!(result.output.contains("monolith"));
815 assert!(result.output.contains("microservices"));
816 }
817
818 #[tokio::test]
819 async fn empty_input_returns_error() {
820 let tool = CloudOpsTool::new(test_config());
821 let result = tool
822 .execute(json!({
823 "action": "review_iac",
824 "input": ""
825 }))
826 .await
827 .unwrap();
828
829 assert!(!result.success);
830 assert!(result.error.is_some());
831 }
832
833 #[tokio::test]
834 async fn unsupported_cloud_returns_error() {
835 let tool = CloudOpsTool::new(test_config());
836 let result = tool
837 .execute(json!({
838 "action": "review_iac",
839 "input": "some content",
840 "cloud": "alibaba"
841 }))
842 .await
843 .unwrap();
844
845 assert!(!result.success);
846 assert!(result.error.unwrap().contains("not in supported_clouds"));
847 }
848
849 #[tokio::test]
850 async fn unknown_action_returns_error() {
851 let tool = CloudOpsTool::new(test_config());
852 let result = tool
853 .execute(json!({
854 "action": "deploy_everything",
855 "input": "some content"
856 }))
857 .await
858 .unwrap();
859
860 assert!(!result.success);
861 assert!(result.error.unwrap().contains("Unknown action"));
862 }
863
864 #[test]
865 fn detect_iac_type_identifies_cloudformation() {
866 assert_eq!(detect_iac_type("AWS::EC2::Instance"), "cloudformation");
867 }
868
869 #[test]
870 fn detect_iac_type_identifies_pulumi() {
871 assert_eq!(detect_iac_type("import pulumi"), "pulumi");
872 }
873
874 #[test]
875 fn scan_iac_security_finds_wildcard_permission() {
876 let findings = scan_iac_security("Action: \"*\" Effect: Allow");
877 assert!(!findings.is_empty());
878 let msg = findings[0]["message"].as_str().unwrap();
879 assert!(msg.contains("Wildcard permission"));
880 }
881
882 #[test]
883 fn scan_iac_cost_gates_aws_patterns_for_non_aws() {
884 let findings = scan_iac_cost(
886 "nat_gateway elastic_ip load_balancer instance_type",
887 "azure",
888 0.0, );
890 for f in &findings {
891 let msg = f["message"].as_str().unwrap();
892 assert!(
893 !msg.contains("NAT Gateway") && !msg.contains("Elastic IP") && !msg.contains("ALB"),
894 "AWS-specific finding leaked for azure: {}",
895 msg
896 );
897 }
898 assert!(
900 findings
901 .iter()
902 .any(|f| f["message"].as_str().unwrap().contains("instance sizing"))
903 );
904 }
905
906 #[test]
907 fn scan_iac_cost_respects_threshold() {
908 let findings = scan_iac_cost(
910 "nat_gateway elastic_ip instance_type",
911 "aws",
912 200.0, );
914 assert!(
915 findings.is_empty(),
916 "expected no findings above threshold 200, got {:?}",
917 findings
918 );
919 }
920
921 #[tokio::test]
922 async fn non_string_action_returns_error() {
923 let tool = CloudOpsTool::new(test_config());
924 let result = tool
925 .execute(json!({
926 "action": 42,
927 "input": "some content"
928 }))
929 .await;
930
931 assert!(result.is_err());
932 let err_msg = result.unwrap_err().to_string();
933 assert!(err_msg.contains("'action' must be a string"));
934 }
935
936 #[tokio::test]
937 async fn non_string_input_returns_error() {
938 let tool = CloudOpsTool::new(test_config());
939 let result = tool
940 .execute(json!({
941 "action": "review_iac",
942 "input": 123
943 }))
944 .await;
945
946 assert!(result.is_err());
947 let err_msg = result.unwrap_err().to_string();
948 assert!(err_msg.contains("'input' must be a string"));
949 }
950
951 #[tokio::test]
952 async fn non_string_cloud_returns_error() {
953 let tool = CloudOpsTool::new(test_config());
954 let result = tool
955 .execute(json!({
956 "action": "review_iac",
957 "input": "some content",
958 "cloud": true
959 }))
960 .await;
961
962 assert!(result.is_err());
963 let err_msg = result.unwrap_err().to_string();
964 assert!(err_msg.contains("'cloud' must be a string"));
965 }
966}