#![allow(clippy::unwrap_used)] #![allow(clippy::too_many_lines)] #![cfg(feature = "e2e")]
use std::{
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use k8s_openapi::api::core::v1::Namespace;
use kube::{
Api, Client, ResourceExt,
api::{DeleteParams, ListParams, ObjectMeta, Patch, PatchParams, PostParams},
};
use polyc_controller::{
Conversation, ConversationSpec, SandboxClaimBackend,
reconcile::{Context, DEFAULT_TEMPLATE, reconcile},
};
use polyc_k8s_types::sandboxclaim::SandboxClaim;
use serde_json::json;
const NS: &str = "polychrome-e2e";
async fn ensure_namespace(client: &Client) {
let api: Api<Namespace> = Api::all(client.clone());
let ns = Namespace {
metadata: ObjectMeta {
name: Some(NS.to_owned()),
..ObjectMeta::default()
},
..Namespace::default()
};
api.patch(
NS,
&PatchParams::apply("polychrome-e2e"),
&Patch::Apply(&ns),
)
.await
.expect("create/ensure namespace");
}
#[tokio::test]
async fn conversation_reconciles_into_owned_sandbox_claim() {
let _ = rustls::crypto::ring::default_provider().install_default();
let client = Client::try_default()
.await
.expect("kube client from current context (e.g. orbstack)");
assert!(
Api::<Conversation>::all(client.clone())
.list(&ListParams::default())
.await
.is_ok(),
"Conversation CRD not installed; run: cargo run --bin crdgen | kubectl apply -f -"
);
ensure_namespace(&client).await;
let convs: Api<Conversation> = Api::namespaced(client.clone(), NS);
let claims: Api<SandboxClaim> = Api::namespaced(client.clone(), NS);
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let name = format!("e2e-{suffix}");
let mut conv = Conversation::new(
&name,
ConversationSpec {
model: "fast-2".to_owned(),
principal_ref: "persona-e2e".to_owned(),
idle_timeout_seconds: 300,
tools_enabled: vec![],
tools_disabled: vec![],
parent_conversation_id: None,
agent_id: None,
},
);
conv.metadata.namespace = Some(NS.to_owned());
let created = convs
.create(&PostParams::default(), &conv)
.await
.expect("create Conversation");
let conv_uid = created.uid().expect("created conv has uid");
let ctx = Arc::new(Context::new(
client.clone(),
DEFAULT_TEMPLATE.to_owned(),
Arc::new(SandboxClaimBackend::new(client.clone())),
None,
5,
None,
));
for _ in 0..2 {
let latest = convs.get(&name).await.expect("get conv");
reconcile(Arc::new(latest), ctx.clone())
.await
.expect("reconcile pass");
}
let claim = claims.get(&name).await.expect("SandboxClaim was created");
assert_eq!(claim.spec.sandbox_template_ref.name, DEFAULT_TEMPLATE);
let owners = claim.metadata.owner_references.clone().unwrap_or_default();
assert!(
owners
.iter()
.any(|o| o.kind == "Conversation" && o.uid == conv_uid && o.controller == Some(true)),
"SandboxClaim must be controller-owned by the Conversation; got {owners:?}"
);
let after = convs.get_status(&name).await.expect("get status");
assert_eq!(
after.status.and_then(|s| s.sandbox_claim_name).as_deref(),
Some(name.as_str())
);
let claim_status = json!({ "status": {
"sandbox": { "name": format!("{name}-sbx"), "podIPs": ["10.4.2.7"] },
"conditions": [{
"type": "Ready",
"status": "True",
"reason": "PodRunning",
"message": "",
"lastTransitionTime": "2026-05-27T00:00:00Z",
}],
} });
claims
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&claim_status))
.await
.expect("patch SandboxClaim status");
let latest = convs.get(&name).await.expect("get conv");
reconcile(Arc::new(latest), ctx.clone())
.await
.expect("status-sync reconcile");
let synced = convs
.get_status(&name)
.await
.expect("get synced status")
.status
.unwrap_or_default();
assert!(synced.harness_ready, "harness_ready should propagate");
assert_eq!(synced.pod_ip.as_deref(), Some("10.4.2.7"));
assert_eq!(synced.phase.as_deref(), Some("Ready"));
let ready_cond = synced
.conditions
.iter()
.find(|c| c.type_ == "Ready")
.expect("a Ready condition is recorded");
assert_eq!(
ready_cond.status,
polyc_controller::conversation::ConditionStatus::True
);
for other in ["Progressing", "Degraded"] {
let other_cond = synced
.conditions
.iter()
.find(|c| c.type_ == other)
.unwrap_or_else(|| panic!("a {other} condition is recorded"));
assert_eq!(
other_cond.status,
polyc_controller::conversation::ConditionStatus::False,
"{other} should be False once Ready"
);
}
let drift = json!({ "spec": { "lifecycle": { "shutdownPolicy": "Retain" } } });
claims
.patch(&name, &PatchParams::default(), &Patch::Merge(&drift))
.await
.expect("simulate out-of-band SandboxClaim spec edit");
let drifted = claims.get(&name).await.expect("get drifted claim");
assert_eq!(
drifted
.spec
.lifecycle
.as_ref()
.and_then(|l| l.shutdown_policy.clone()),
Some(polyc_k8s_types::sandboxclaim::SandboxClaimLifecycleShutdownPolicy::Retain),
"sanity: the drift actually landed before reconciling"
);
let latest = convs.get(&name).await.expect("get conv");
reconcile(Arc::new(latest), ctx.clone())
.await
.expect("drift-healing reconcile");
let healed = claims.get(&name).await.expect("get healed claim");
assert_eq!(
healed
.spec
.lifecycle
.as_ref()
.and_then(|l| l.shutdown_policy.clone()),
Some(polyc_k8s_types::sandboxclaim::SandboxClaimLifecycleShutdownPolicy::Delete),
"the out-of-band spec drift must be reverted on the next reconcile pass"
);
convs
.delete(&name, &DeleteParams::default())
.await
.expect("delete conv");
if let Ok(terminating) = convs.get(&name).await {
reconcile(Arc::new(terminating), ctx.clone())
.await
.expect("cleanup reconcile");
}
assert!(
claims.get(&name).await.is_err(),
"SandboxClaim should be deleted after cleanup"
);
}