use super::*;
#[derive(Default)]
pub(super) struct WriteState {
pub pending: Option<PendingWrite>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WriteVerb {
Deploy,
Restart,
Rebuild,
Terminate,
SetOption,
}
impl WriteVerb {
fn label(self) -> &'static str {
match self {
WriteVerb::Deploy => "Deploy",
WriteVerb::Restart => "Restart",
WriteVerb::Rebuild => "Rebuild",
WriteVerb::Terminate => "Terminate",
WriteVerb::SetOption => "SetOption",
}
}
}
pub(super) struct PendingWrite {
pub token: String,
pub verb: WriteVerb,
pub env: String,
pub version: Option<String>,
pub settings: Vec<(String, String, String)>,
pub profile: Option<String>,
pub region: Option<String>,
pub expires_at: tokio::time::Instant,
pub name_retry_used: bool,
}
const CONFIRM_TTL_SECS: u64 = 60;
const SET_OPTION_MAX: usize = 10;
fn mint_token() -> String {
use sha2::{Digest, Sha256};
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut h = Sha256::new();
h.update(std::process::id().to_le_bytes());
h.update(n.to_le_bytes());
h.update(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos()
.to_le_bytes(),
);
let digest = h.finalize();
digest[..16].iter().map(|b| format!("{b:02x}")).collect()
}
pub(super) fn write_tool_descriptors() -> Vec<Value> {
let confirm_note = "TWO-PHASE: this tool DISPATCHES NOTHING. It validates and returns {pending:true, confirm_token, plan}; you must surface the plan, then call confirm_action with the token (60s TTL, single-use) to dispatch. Dispatch-only — poll the read tools for progress.";
vec![
json!({
"name": "deploy",
"description": format!("Deploy an existing application version to an environment. {confirm_note}"),
"inputSchema": {
"type": "object",
"properties": {
"env": {"type": "string", "description": "Environment name (required)"},
"version": {"type": "string", "description": "Existing application version label (required)"},
"profile": {"type": "string"},
"region": {"type": "string"}
},
"required": ["env", "version"]
}
}),
json!({
"name": "restart",
"description": format!("Restart the app server on an environment's instances. {confirm_note}"),
"inputSchema": {
"type": "object",
"properties": {
"env": {"type": "string", "description": "Environment name (required)"},
"profile": {"type": "string"},
"region": {"type": "string"}
},
"required": ["env"]
}
}),
json!({
"name": "rebuild",
"description": format!("Rebuild an environment (replaces its resources). {confirm_note}"),
"inputSchema": {
"type": "object",
"properties": {
"env": {"type": "string", "description": "Environment name (required)"},
"profile": {"type": "string"},
"region": {"type": "string"}
},
"required": ["env"]
}
}),
json!({
"name": "terminate",
"description": format!("TERMINATE an environment — destructive and irreversible. {confirm_note} Additionally, confirm_action requires confirm_name equal to the env name (strict-typed confirm; one retry per token)."),
"inputSchema": {
"type": "object",
"properties": {
"env": {"type": "string", "description": "Environment name (required)"},
"profile": {"type": "string"},
"region": {"type": "string"}
},
"required": ["env"]
}
}),
json!({
"name": "set_option",
"description": format!("Update up to {SET_OPTION_MAX} option settings on one environment. Namespaces must already exist in the env's configuration (no cross-env blast). {confirm_note} The plan shows old -> new per setting; old env-var values are redacted per the standing contract."),
"inputSchema": {
"type": "object",
"properties": {
"env": {"type": "string", "description": "Environment name (required)"},
"settings": {
"type": "array",
"description": "Settings to apply (max 10)",
"items": {
"type": "object",
"properties": {
"namespace": {"type": "string"},
"name": {"type": "string"},
"value": {"type": "string"}
},
"required": ["namespace", "name", "value"]
}
},
"profile": {"type": "string"},
"region": {"type": "string"}
},
"required": ["env", "settings"]
}
}),
json!({
"name": "confirm_action",
"description": "Phase 2 of every write tool: dispatch the pending plan identified by confirm_token (single-use, 60s TTL). terminate additionally requires confirm_name equal to the plan's env name. Writes are serialized — a confirm while another dispatch is in flight is refused.",
"inputSchema": {
"type": "object",
"properties": {
"confirm_token": {"type": "string", "description": "Token from the write tool's pending plan (required)"},
"confirm_name": {"type": "string", "description": "terminate only: must equal the env name"}
},
"required": ["confirm_token"]
}
}),
]
}
fn write_gate(
safety_cfg: &crate::config::Config,
env: &str,
profile: &Option<String>,
active_freeze: Option<crate::freeze::FreezeMarker>,
) -> Option<String> {
if let Some(m) = active_freeze {
let reason = if m.reason.is_empty() {
"no reason given".to_string()
} else {
m.reason.clone()
};
return Some(format!(
"fleet freeze active ({reason}) — lift with `{}` in the owning TUI (pid {})",
m.remedy(),
m.pid
));
}
let pin_profile = profile
.clone()
.or_else(|| std::env::var("AWS_PROFILE").ok());
if let Some(pin) = safety_cfg.pin_reason(env, pin_profile.as_deref()) {
return Some(format!("refusing {env} — pinned by {pin}"));
}
None
}
impl Server {
pub(super) async fn tool_write_plan(
&self,
verb: WriteVerb,
args: &Value,
) -> Result<String, String> {
if !self.allow_writes {
return Err("writes are disabled — start the server with --allow-writes".into());
}
if self.dispatching.load(std::sync::atomic::Ordering::SeqCst) {
return Err("another write is in flight — wait for it to complete".into());
}
let env_name = arg_str(args, "env").ok_or("'env' is required")?;
let profile = arg_str(args, "profile");
if let Some(msg) = write_gate(
&self.safety_cfg,
&env_name,
&profile,
crate::freeze::read_active(),
) {
return Err(msg);
}
let envs = self.fetch_envs(args).await?;
let env = envs
.iter()
.find(|e| e.name == env_name)
.ok_or_else(|| format!("env '{env_name}' not found"))?
.clone();
let mut version: Option<String> = None;
let mut settings: Vec<(String, String, String)> = Vec::new();
let mut plan_extra = String::new();
match verb {
WriteVerb::Deploy => {
let label = arg_str(args, "version").ok_or("'version' is required")?;
let known = match self.backend {
Backend::Demo => demo_fixture::deploys_for_app(&env.application)
.iter()
.any(|v| v.label == label),
Backend::Aws => {
let client = self.client(args).await?;
client
.list_application_versions(&env.application)
.await
.map_err(|e| {
tool_error(&profile, "list_application_versions", &e.to_string())
})?
.iter()
.any(|v| v.label == label)
}
};
if !known {
return Err(format!(
"version '{label}' does not exist for application '{}'",
env.application
));
}
plan_extra = format!(
",\"current_version\":{},\"target_version\":{}",
util::json_string(&env.version_label),
util::json_string(&label),
);
version = Some(label);
}
WriteVerb::SetOption => {
let raw = args
.get("settings")
.and_then(Value::as_array)
.ok_or("'settings' (array) is required")?;
if raw.is_empty() {
return Err("'settings' is empty".into());
}
if raw.len() > SET_OPTION_MAX {
return Err(format!(
"set_option caps at {SET_OPTION_MAX} settings per call (got {})",
raw.len()
));
}
for s in raw {
let ns = s.get("namespace").and_then(Value::as_str).unwrap_or("");
let name = s.get("name").and_then(Value::as_str).unwrap_or("");
let value = s.get("value").and_then(Value::as_str).unwrap_or("");
if ns.is_empty() || name.is_empty() {
return Err("each setting needs non-empty namespace and name".into());
}
settings.push((ns.to_string(), name.to_string(), value.to_string()));
}
let current: Vec<(String, String, String)> = match self.backend {
Backend::Demo => demo_fixture::option_settings_for(&env.name),
Backend::Aws => {
let client = self.client(args).await?;
client
.fetch_env_option_settings(&env.application, &env.name)
.await
.map_err(|e| {
tool_error(&profile, "fetch_env_option_settings", &e.to_string())
})?
}
};
let known_ns: std::collections::HashSet<&str> =
current.iter().map(|(ns, _, _)| ns.as_str()).collect();
for (ns, _, _) in &settings {
if !known_ns.contains(ns.as_str()) {
return Err(format!(
"namespace '{ns}' is not present in {}'s configuration — refusing (set_option only touches existing namespaces)",
env.name
));
}
}
let rows: Vec<String> = settings
.iter()
.map(|(ns, name, new_v)| {
let old = current
.iter()
.find(|(cns, cn, _)| cns == ns && cn == name)
.map(|(_, _, v)| redact_option_value(ns, name, v, self.redact))
.unwrap_or_else(|| "(unset)".into());
format!(
"{{\"namespace\":{},\"name\":{},\"old\":{},\"new\":{}}}",
util::json_string(ns),
util::json_string(name),
util::json_string(&old),
util::json_string(new_v),
)
})
.collect();
plan_extra = format!(",\"changes\":[{}]", rows.join(","));
}
WriteVerb::Restart | WriteVerb::Rebuild | WriteVerb::Terminate => {}
}
let events_json = match self.backend {
Backend::Demo => String::new(),
Backend::Aws => {
let client = self.client(args).await?;
match client.list_events_for_env(&env.name, 3).await {
Ok(evs) => {
let rows: Vec<String> = evs
.iter()
.map(|e| {
format!(
"{{\"severity\":{},\"message\":{}}}",
util::json_string(&e.severity),
util::json_string(&e.message),
)
})
.collect();
format!(",\"recent_events\":[{}]", rows.join(","))
}
Err(_) => String::new(),
}
}
};
let token = mint_token();
{
let mut st = self.writes.lock().await;
if self.dispatching.load(std::sync::atomic::Ordering::SeqCst) {
return Err("another write is in flight — wait for it to complete".into());
}
st.pending = Some(PendingWrite {
token: token.clone(),
verb,
env: env.name.clone(),
version: version.clone(),
settings: settings.clone(),
profile: profile.clone(),
region: arg_str(args, "region"),
expires_at: tokio::time::Instant::now()
+ std::time::Duration::from_secs(CONFIRM_TTL_SECS),
name_retry_used: false,
});
}
let next = if verb == WriteVerb::Terminate {
format!(
"call confirm_action with the confirm_token AND confirm_name={} to dispatch",
env.name
)
} else {
"call confirm_action with the confirm_token to dispatch".to_string()
};
Ok(format!(
"{{\"pending\":true,\"confirm_token\":{},\"expires_in_secs\":{CONFIRM_TTL_SECS},\"plan\":{{\"action\":{},\"env\":{},\"application\":{},\"health\":{},\"status\":{}{plan_extra}{events_json}}},\"next\":{}}}",
util::json_string(&token),
util::json_string(verb.label()),
util::json_string(&env.name),
util::json_string(&env.application),
util::json_string(&env.health),
util::json_string(&env.status),
util::json_string(&next),
))
}
pub(super) async fn tool_confirm_action(&self, args: &Value) -> Result<String, String> {
if !self.allow_writes {
return Err("writes are disabled — start the server with --allow-writes".into());
}
let token = arg_str(args, "confirm_token").ok_or("'confirm_token' is required")?;
let pending = {
let mut st = self.writes.lock().await;
if self.dispatching.load(std::sync::atomic::Ordering::SeqCst) {
return Err("another write is in flight — wait for it to complete".into());
}
let Some(p) = st.pending.as_mut() else {
return Err("no pending write — call a write tool first".into());
};
if p.token != token {
return Err("unknown confirm_token — re-plan required".into());
}
if tokio::time::Instant::now() >= p.expires_at {
st.pending = None;
return Err("confirm_token expired — re-plan required".into());
}
if p.verb == WriteVerb::Terminate {
let supplied = arg_str(args, "confirm_name").unwrap_or_default();
if supplied != p.env {
if p.name_retry_used {
st.pending = None;
return Err(
"confirm_name mismatch twice — plan dropped, re-plan required".into(),
);
}
p.name_retry_used = true;
return Err(format!(
"confirm_name must equal the env name ({}) — one retry remains on this token",
p.env
));
}
}
if let Some(msg) = write_gate(
&self.safety_cfg,
&p.env,
&p.profile,
crate::freeze::read_active(),
) {
st.pending = None;
return Err(msg);
}
self.dispatching
.store(true, std::sync::atomic::Ordering::SeqCst);
st.pending.take().expect("checked above")
};
struct DispatchGuard<'a>(&'a std::sync::atomic::AtomicBool);
impl Drop for DispatchGuard<'_> {
fn drop(&mut self) {
self.0.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
let _guard = DispatchGuard(&self.dispatching);
self.dispatch_write(&pending).await
}
async fn dispatch_write(&self, p: &PendingWrite) -> Result<String, String> {
let verb_label = p.verb.label();
if matches!(self.backend, Backend::Demo) {
return Ok(format!(
"{{\"dispatched\":true,\"demo\":true,\"action\":{},\"env\":{}}}",
util::json_string(verb_label),
util::json_string(&p.env),
));
}
let args = json!({
"profile": p.profile.clone().unwrap_or_default(),
"region": p.region.clone().unwrap_or_default(),
});
let client = self.client(&args).await?;
let client_name = self
.client_name
.lock()
.map(|s| s.clone())
.unwrap_or_else(|_| "unknown".into());
let audit_profile = p
.profile
.clone()
.or_else(|| std::env::var("AWS_PROFILE").ok());
let mut extras: Vec<(&str, String)> =
vec![("via", "mcp".to_string()), ("client", client_name.clone())];
if let Some(v) = &p.version {
extras.push(("version", v.clone()));
}
if !p.settings.is_empty() {
extras.push(("settings", p.settings.len().to_string()));
}
let extras_ref: Vec<(&str, &str)> = extras.iter().map(|(k, v)| (*k, v.as_str())).collect();
crate::audit::append_action_dispatched(
None,
audit_profile.as_deref(),
&client.context.region,
verb_label,
&p.env,
&extras_ref,
);
let outcome: Result<(), String> = match p.verb {
WriteVerb::Deploy => client
.deploy_version(&p.env, p.version.as_deref().unwrap_or_default())
.await
.map_err(|e| e.to_string()),
WriteVerb::Restart => client
.restart_app_server(&p.env)
.await
.map_err(|e| e.to_string()),
WriteVerb::Rebuild => client.rebuild_env(&p.env).await.map_err(|e| e.to_string()),
WriteVerb::Terminate => client
.terminate_env(&p.env)
.await
.map_err(|e| e.to_string()),
WriteVerb::SetOption => client
.update_env_option_settings(&p.env, &p.settings, &[])
.await
.map_err(|e| e.to_string()),
};
crate::audit::append_action_completed(
None,
audit_profile.as_deref(),
&client.context.region,
verb_label,
&p.env,
match &outcome {
Ok(()) => Ok(()),
Err(e) => Err(e.as_str()),
},
&extras_ref,
);
match outcome {
Ok(()) => Ok(format!(
"{{\"dispatched\":true,\"action\":{},\"env\":{},\"note\":\"dispatch-only — poll list_environments / recent_events for progress\"}}",
util::json_string(verb_label),
util::json_string(&p.env),
)),
Err(e) => Err(tool_error(&p.profile, verb_label, &e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_gate_refuses_under_freeze_and_pin() {
let cfg = crate::config::Config::default();
assert!(write_gate(&cfg, "prod", &None, None).is_none());
let m = crate::freeze::FreezeMarker {
pid: 4242,
reason: "checkout 5xx".into(),
incident: true,
at: "now".into(),
};
let msg = write_gate(&cfg, "prod", &None, Some(m)).expect("refused");
assert!(msg.contains("freeze active") && msg.contains(":incident END"));
let mut pinned = crate::config::Config::default();
pinned.safety_envs.insert("prod".into(), true);
let msg2 = write_gate(&pinned, "prod", &None, None).expect("pin refused");
assert!(msg2.contains("pinned by"));
}
}