Skip to content

Commit 14f3e82

Browse files
Alex Holmbergclaude
authored andcommitted
fix(deploy): add duplicate detection and environment display to DeployServiceTool
Prevents creating duplicate deployment configs by: - Checking existing configs before deploying - Routing to redeploy if service already exists - Showing environment info (name, is_production) in preview - Adding production deployment warnings - Showing REDEPLOY vs NEW_DEPLOYMENT mode clearly Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 389bde7 commit 14f3e82

1 file changed

Lines changed: 177 additions & 52 deletions

File tree

src/agent/tools/platform/deploy_service.rs

Lines changed: 177 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,50 @@ User: "deploy this service"
232232
let project_id = session.project_id.clone().unwrap_or_default();
233233
let environment_id = session.environment_id.clone();
234234

235-
// 4. Get available providers
235+
// 4. Check for existing deployment configs (duplicate detection)
236+
let existing_configs = match client.list_deployment_configs(&project_id).await {
237+
Ok(configs) => configs,
238+
Err(e) => {
239+
// Non-fatal - continue without duplicate detection
240+
tracing::warn!("Failed to fetch existing configs: {}", e);
241+
Vec::new()
242+
}
243+
};
244+
245+
// Get service name early to check for duplicates
246+
let service_name = get_service_name(&analysis_path);
247+
248+
// Find existing config with same service name
249+
let existing_config = existing_configs
250+
.iter()
251+
.find(|c| c.service_name.eq_ignore_ascii_case(&service_name));
252+
253+
// 5. Get environment info for display
254+
let environments = match client.list_environments(&project_id).await {
255+
Ok(envs) => envs,
256+
Err(_) => Vec::new(),
257+
};
258+
259+
// Resolve environment name for display
260+
let (resolved_env_id, resolved_env_name, is_production) = if let Some(ref env_id) = environment_id {
261+
let env = environments.iter().find(|e| e.id == *env_id);
262+
let name = env.map(|e| e.name.clone()).unwrap_or_else(|| "Unknown".to_string());
263+
let is_prod = name.to_lowercase().contains("prod");
264+
(env_id.clone(), name, is_prod)
265+
} else if let Some(existing) = &existing_config {
266+
// Use the environment from existing config
267+
let env = environments.iter().find(|e| e.id == existing.environment_id);
268+
let name = env.map(|e| e.name.clone()).unwrap_or_else(|| "Unknown".to_string());
269+
let is_prod = name.to_lowercase().contains("prod");
270+
(existing.environment_id.clone(), name, is_prod)
271+
} else if let Some(first_env) = environments.first() {
272+
let is_prod = first_env.name.to_lowercase().contains("prod");
273+
(first_env.id.clone(), first_env.name.clone(), is_prod)
274+
} else {
275+
("".to_string(), "No environment".to_string(), false)
276+
};
277+
278+
// 6. Get available providers
236279
let capabilities = match get_provider_deployment_statuses(&client, &project_id).await {
237280
Ok(c) => c,
238281
Err(e) => {
@@ -297,13 +340,63 @@ User: "deploy this service"
297340
.map(|i| i.has_kubernetes)
298341
.unwrap_or(false);
299342

300-
// 8. Get service name
301-
let service_name = get_service_name(&analysis_path);
302-
303-
// 9. If preview_only, return recommendation
343+
// 10. If preview_only, return recommendation
304344
if args.preview_only {
345+
// Build the deployment mode info
346+
let (deployment_mode, mode_explanation, next_steps) = if let Some(existing) = &existing_config {
347+
(
348+
"REDEPLOY",
349+
format!(
350+
"Service '{}' already has a deployment config (ID: {}). Deploying will trigger a REDEPLOY of the existing service.",
351+
existing.service_name, existing.id
352+
),
353+
vec![
354+
"To redeploy with current config: call deploy_service with preview_only=false".to_string(),
355+
"This will trigger a new deployment of the existing service".to_string(),
356+
"The existing configuration will be used".to_string(),
357+
]
358+
)
359+
} else {
360+
(
361+
"NEW_DEPLOYMENT",
362+
format!(
363+
"No existing deployment config found for '{}'. This will create a NEW deployment configuration.",
364+
service_name
365+
),
366+
vec![
367+
"To deploy with these settings: call deploy_service with preview_only=false".to_string(),
368+
"To customize: specify provider, machine_type, region, or port parameters".to_string(),
369+
"To see more options: check the alternatives section above".to_string(),
370+
]
371+
)
372+
};
373+
374+
// Production warning
375+
let production_warning = if is_production {
376+
Some("⚠️ WARNING: This will deploy to PRODUCTION environment. Please confirm you intend to deploy to production.")
377+
} else {
378+
None
379+
};
380+
305381
let response = json!({
306382
"status": "recommendation",
383+
"deployment_mode": deployment_mode,
384+
"mode_explanation": mode_explanation,
385+
"environment": {
386+
"id": resolved_env_id,
387+
"name": resolved_env_name,
388+
"is_production": is_production,
389+
},
390+
"production_warning": production_warning,
391+
"existing_config": existing_config.map(|c| json!({
392+
"id": c.id,
393+
"service_name": c.service_name,
394+
"environment_id": c.environment_id,
395+
"branch": c.branch,
396+
"port": c.port,
397+
"auto_deploy_enabled": c.auto_deploy_enabled,
398+
"created_at": c.created_at.to_rfc3339(),
399+
})),
307400
"analysis": {
308401
"path": analysis_path.display().to_string(),
309402
"language": primary_language,
@@ -345,26 +438,73 @@ User: "deploy this service"
345438
})).collect::<Vec<_>>(),
346439
},
347440
"service_name": service_name,
348-
"next_steps": [
349-
"To deploy with these settings: call deploy_service with preview_only=false",
350-
"To customize: specify provider, machine_type, region, or port parameters",
351-
"To see more options: check the alternatives section above",
352-
],
353-
"confirmation_prompt": format!(
354-
"Deploy '{}' to {} ({}) with {} in {}?",
355-
service_name,
356-
recommendation.provider.display_name(),
357-
recommendation.target.display_name(),
358-
recommendation.machine_type,
359-
recommendation.region
360-
),
441+
"next_steps": next_steps,
442+
"confirmation_prompt": if existing_config.is_some() {
443+
format!(
444+
"REDEPLOY '{}' to {} environment?{}",
445+
service_name,
446+
resolved_env_name,
447+
if is_production { " ⚠️ (PRODUCTION)" } else { "" }
448+
)
449+
} else {
450+
format!(
451+
"Deploy NEW service '{}' to {} ({}) with {} in {} on {} environment?{}",
452+
service_name,
453+
recommendation.provider.display_name(),
454+
recommendation.target.display_name(),
455+
recommendation.machine_type,
456+
recommendation.region,
457+
resolved_env_name,
458+
if is_production { " ⚠️ (PRODUCTION)" } else { "" }
459+
)
460+
},
361461
});
362462

363463
return serde_json::to_string_pretty(&response)
364464
.map_err(|e| DeployServiceError(format!("Failed to serialize: {}", e)));
365465
}
366466

367-
// 10. Execute deployment
467+
// 11. Execute deployment - EITHER redeploy existing OR create new
468+
469+
// If existing config found, trigger redeploy instead of creating new config
470+
if let Some(existing) = &existing_config {
471+
let trigger_request = TriggerDeploymentRequest {
472+
project_id: project_id.clone(),
473+
config_id: existing.id.clone(),
474+
commit_sha: None,
475+
};
476+
477+
return match client.trigger_deployment(&trigger_request).await {
478+
Ok(response) => {
479+
let result = json!({
480+
"status": "redeployed",
481+
"deployment_mode": "REDEPLOY",
482+
"config_id": existing.id,
483+
"task_id": response.backstage_task_id,
484+
"service_name": service_name,
485+
"environment": {
486+
"id": resolved_env_id,
487+
"name": resolved_env_name,
488+
"is_production": is_production,
489+
},
490+
"message": format!(
491+
"Redeploy triggered for existing service '{}' on {} environment. Task ID: {}",
492+
service_name, resolved_env_name, response.backstage_task_id
493+
),
494+
"next_steps": [
495+
format!("Monitor progress: use get_deployment_status with task_id '{}'", response.backstage_task_id),
496+
"View logs after deployment: use get_service_logs",
497+
],
498+
});
499+
500+
serde_json::to_string_pretty(&result)
501+
.map_err(|e| DeployServiceError(format!("Failed to serialize: {}", e)))
502+
}
503+
Err(e) => Ok(format_api_error("deploy_service", e)),
504+
};
505+
}
506+
507+
// NEW DEPLOYMENT PATH - no existing config found
368508
let final_provider = args.provider
369509
.as_ref()
370510
.and_then(|p| CloudProvider::from_str(p).ok())
@@ -409,36 +549,15 @@ User: "deploy this service"
409549
}
410550
};
411551

412-
// Get environment from session or use default
413-
let env_id = match &environment_id {
414-
Some(id) => id.clone(),
415-
None => {
416-
// Try to get environments from API
417-
match client.list_environments(&project_id).await {
418-
Ok(envs) => {
419-
match envs.first() {
420-
Some(env) => env.id.clone(),
421-
None => {
422-
return Ok(format_error_for_llm(
423-
"deploy_service",
424-
ErrorCategory::ResourceUnavailable,
425-
"No environment found for project",
426-
Some(vec!["Create an environment in the platform first"]),
427-
));
428-
}
429-
}
430-
}
431-
Err(e) => {
432-
return Ok(format_error_for_llm(
433-
"deploy_service",
434-
ErrorCategory::NetworkError,
435-
&format!("Failed to get environments: {}", e),
436-
None,
437-
));
438-
}
439-
}
440-
}
441-
};
552+
// Use resolved environment ID from earlier
553+
if resolved_env_id.is_empty() {
554+
return Ok(format_error_for_llm(
555+
"deploy_service",
556+
ErrorCategory::ResourceUnavailable,
557+
"No environment found for project",
558+
Some(vec!["Create an environment in the platform first"]),
559+
));
560+
}
442561

443562
// Build deployment config request
444563
// Derive dockerfile path and build context from DockerfileInfo
@@ -485,7 +604,7 @@ User: "deploy this service"
485604
branch: repo.default_branch.clone().unwrap_or_else(|| "main".to_string()),
486605
target_type: recommendation.target.as_str().to_string(),
487606
cloud_provider: final_provider.as_str().to_string(),
488-
environment_id: env_id.clone(),
607+
environment_id: resolved_env_id.clone(),
489608
cluster_id: None, // Cloud Runner doesn't need cluster
490609
registry_id: None, // Auto-provision
491610
auto_deploy_enabled: true,
@@ -512,16 +631,22 @@ User: "deploy this service"
512631
Ok(response) => {
513632
let result = json!({
514633
"status": "deployed",
634+
"deployment_mode": "NEW_DEPLOYMENT",
515635
"config_id": config.id,
516636
"task_id": response.backstage_task_id,
517637
"service_name": service_name,
638+
"environment": {
639+
"id": resolved_env_id,
640+
"name": resolved_env_name,
641+
"is_production": is_production,
642+
},
518643
"provider": final_provider.as_str(),
519644
"machine_type": final_machine,
520645
"region": final_region,
521646
"port": final_port,
522647
"message": format!(
523-
"Deployment started for '{}'. Task ID: {}",
524-
service_name, response.backstage_task_id
648+
"NEW deployment started for '{}' on {} environment. Task ID: {}",
649+
service_name, resolved_env_name, response.backstage_task_id
525650
),
526651
"next_steps": [
527652
format!("Monitor progress: use get_deployment_status with task_id '{}'", response.backstage_task_id),

0 commit comments

Comments
 (0)