polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
//! The `ServiceDefinition` custom resource (`polychrome.dev/v1alpha1`).
//!
//! A `ServiceDefinition` is the catalog's **deployable-workload noun**: a
//! constrained, declarative description of one long-running HTTP service that
//! the controller fans out into a `Deployment` + `Service` in the apps
//! namespace. It is the only shape an agent is allowed to emit when it stands
//! up a workload — the agent never writes raw manifests, so the small typed
//! `spec` below *is* the guardrail (what image, which port, how many replicas,
//! nothing else).
//!
//! Same envelope discipline as the rest of the catalog (`ToolService`,
//! `Conversation`): `kubectl apply` of one of these is the whole deployment
//! API, and the controller reflects observed readiness into the `status`
//! subresource the way `claim_readiness` does for a `Conversation`.

use std::collections::BTreeMap;

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Default container port when the spec omits one. Also the single source of
/// the port default the `Workflow` reconciler stamps onto a stage.
#[must_use]
pub const fn default_port() -> i32 {
    8080
}

/// Default replica count when the spec omits one. Shared with the `Workflow`
/// reconciler so the two never drift.
#[must_use]
pub const fn default_replicas() -> i32 {
    1
}

/// Desired state of one deployable service.
///
/// Deliberately small: the fields an agent may choose are the fields that
/// exist. Everything operational the spec does *not* express (security
/// context, resource limits, probes) is stamped uniformly by the reconciler,
/// not chosen per-service.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
    group = "polychrome.dev",
    version = "v1alpha1",
    kind = "ServiceDefinition",
    namespaced,
    status = "ServiceDefinitionStatus",
    shortname = "svcdef",
    category = "polychrome",
    derive = "PartialEq",
    printcolumn = r#"{"name":"Ready","type":"boolean","jsonPath":".status.ready"}"#,
    printcolumn = r#"{"name":"Image","type":"string","jsonPath":".spec.image"}"#,
    printcolumn = r#"{"name":"Template","type":"string","jsonPath":".spec.template"}"#,
    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ServiceDefinitionSpec {
    /// Human-readable description of what this service is.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Entity reference of the owner that requested this service (catalog
    /// `{kind}:{namespace}/{name}` grammar, e.g. `"user:slack/U123"`). The
    /// reconciler itself does not interpret it (fan-out into a `Deployment` +
    /// `Service` is owner-agnostic) — enforcement lives one layer up, in the
    /// `polyc-scaffold` MCP connector (`#789`), which stamps this field from
    /// the caller's own attributed identity on create (never a model-supplied
    /// value) and filters every list/get/delete by it. Direct `kubectl`
    /// access bypasses that filter, same as any other RBAC-gated resource.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// Name of the catalog template this service was scaffolded from
    /// (e.g. `"rust-hello-world"`) — the lineage answer to "re-stamp
    /// everything built from template v2".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub template: Option<String>,
    /// Container image to run. The reconciler deploys exactly this reference;
    /// CI updates it on new builds.
    pub image: String,
    /// HTTP port the container listens on (also used for the readiness probe
    /// and the fronting `Service`).
    #[serde(default = "default_port")]
    pub port: i32,
    /// Desired replica count.
    #[serde(default = "default_replicas")]
    pub replicas: i32,
    /// Plain environment variables for the container. Secrets do not belong
    /// here — a secret-reference field is a deliberate later addition.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
}

/// Observed state, written back to the `status` subresource by the controller.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServiceDefinitionStatus {
    /// `true` when the owned `Deployment` reports all desired replicas
    /// available.
    #[serde(default)]
    pub ready: bool,
    /// Available replicas reported by the owned `Deployment`.
    #[serde(default)]
    pub available_replicas: i32,
    /// Lifecycle phase: `Pending` until the deployment is available, then
    /// `Ready`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phase: Option<String>,
    /// Human-readable status detail (e.g. a rollout error reason).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}