polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
#![allow(clippy::unwrap_used)] // test/example/bench: panics are acceptable
#![allow(clippy::too_many_lines)] // test/example/bench: long linear setup is fine
//! Live-cluster end-to-end test for the reconciler.
//!
//! Gated behind the `e2e` feature. It is clippy-checked in CI (the lint step
//! runs `--all-features`) but never executed there — running it needs a live
//! cluster. Run locally against the current kube context (e.g. OrbStack):
//!
//! ```text
//! # one-time: apply the CRDs
//! cargo run -p polyc-controller --bin crdgen | kubectl apply -f -
//! kubectl apply -f crates/k8s-types/crds/
//! # then:
//! cargo test -p polyc-controller --features e2e -- --nocapture
//! ```
//!
//! It drives [`reconcile`] directly (rather than the full watch loop) so the
//! sequence is deterministic: create a `Conversation` → reconcile adds the
//! finalizer → reconcile creates an owned `SandboxClaim` → assert → delete and
//! reconcile once more to run cleanup.
#![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() {
    // rustls 0.23 won't auto-select a crypto provider when several are linked.
    let _ = rustls::crypto::ring::default_provider().install_default();

    let client = Client::try_default()
        .await
        .expect("kube client from current context (e.g. orbstack)");

    // Fail loudly if the CRDs aren't applied — that's a setup error, not a bug.
    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);

    // Unique name per run so reruns don't collide with a finalizer-blocked object.
    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())),
        // Roll-on-image-change is off by default (issue #117 phase 2) —
        // this e2e test exercises the ordinary create/sync lifecycle, not
        // the roll path.
        None,
        5,
        // Closed-conversation GC off — this test never closes the conversation.
        None,
    ));

    // Pass 1: adds the finalizer. Pass 2: creates the SandboxClaim + status.
    for _ in 0..2 {
        let latest = convs.get(&name).await.expect("get conv");
        reconcile(Arc::new(latest), ctx.clone())
            .await
            .expect("reconcile pass");
    }

    // The owned SandboxClaim exists, points at the default template, and has a
    // controller owner reference back to the Conversation.
    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:?}"
    );

    // Status was recorded.
    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())
    );

    // Simulate the agent-sandbox controller marking the claim Ready (OrbStack
    // runs no such controller), then reconcile once more and assert the
    // readiness is mirrored into the Conversation status (reconcile step 3).
    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"));

    // Issue #802: `kubectl get conversation` must show meaningful
    // `status.conditions`, not just the free-form `phase` string. Once ready,
    // the `Ready` condition is `True` and `Progressing`/`Degraded` are `False`.
    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"
        );
    }

    // Issue #802: an out-of-band SandboxClaim spec edit (the trust-boundary
    // failure mode #254 already burned us on once) must be healed on the
    // NEXT reconcile pass, not persist until the claim is deleted and
    // recreated. Simulate a `kubectl edit sandboxclaim` that flips the
    // lifecycle shutdown policy away from our desired `Delete`.
    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"
    );

    // Cleanup: delete sets a deletionTimestamp (finalizer blocks removal); one
    // more reconcile runs the cleanup branch, deletes the claim, drops the
    // finalizer, and lets the object go.
    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"
    );
}