From c38361a2d2db5e9e54ae4c59a6f2d960c9edefc3 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:56:17 +0800 Subject: [PATCH 1/2] feat(studio-cp,oabctl): k8s deploy_provision dispatch (studio#104, resumed after #111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumes #104's k8s deploy_provision work now that #111 (#112/#113) gave both drivers a shared, provider-agnostic create-vs-redeploy branch point — load_manifest is just an S3 key lookup, it doesn't care which Runtime variant a stored manifest holds. - oabctl::studio_api::provision_k8s: provision_manifest's k8s counterpart, applies through K8sDriver instead of EcsDriver. Takes two separate credential contexts (aws_config for the S3 bundle carrier — hooks.pre_seed is provider-agnostic, still S3 regardless of runtime — and a kubeconfig context for the actual apply) since k8s provisioning genuinely needs both simultaneously, unlike the ECS path where one SdkConfig covers everything. - studio-cp::build_default_k8s_manifest: Runtime::Kubernetes counterpart to build_default_manifest. No VPC/subnet/SG (ECS-only networking concept); service_account comes from K8sFleetBinding.expected_principal when it names one (k8s_service_account_from_principal extracts the bare name from the system:serviceaccount:: form the New Fleet wizard's service- account picker writes — KubernetesRuntime.service_account wants the bare name, verified against k8s_driver.rs's own build_deployment_wires_service_account_and_node_selector test). - studio-cp::provision_from_library_k8s: provision_from_library's k8s counterpart. Compose/bundle-upload logic is duplicated rather than shared for now (deliberate — avoids reworking provision_from_library's shape again while it's still unmerged; worth revisiting once both paths are proven). Redeploy preserves the stored manifest's k8s runtime config, only bumps the image, same guarantee the AWS path gives. Nothing calls provision_from_library_k8s yet — oab-mcp's deploy_provision tool still needs a provider param to dispatch to it (the OabMcp struct also has no k8s-fleet-binding awareness yet to resolve context/expected_principal from). That wiring is the next piece. Manually verified every field against manifest.rs's struct definitions, same care as #113 given the environment can't locally compile (aws-sdk-ec2 OOM) — CI is the gate. Ref: studio#104, studio#111. --- crates/oabctl/src/studio_api.rs | 37 ++++++++ crates/studio-cp/src/lib.rs | 159 ++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs index ed1b2d5..5097ae0 100644 --- a/crates/oabctl/src/studio_api.rs +++ b/crates/oabctl/src/studio_api.rs @@ -245,6 +245,43 @@ pub async fn provision_manifest( provision(config, cluster, &yaml, objects, control_plane_bucket).await } +/// [`provision_manifest`], but applies through [`crate::k8s_driver::K8sDriver`] +/// instead of [`crate::driver::EcsDriver`] (studio#104's k8s `deploy_provision` +/// dispatch, resumed once #111 landed a create-vs-redeploy branch point that +/// doesn't care which driver applies the result). +/// +/// Takes **two** separate credential contexts, unlike every other function in +/// this module — this is inherent to how k8s provisioning works here, not +/// something to simplify away: the bundle carrier (`hooks.pre_seed`, studio#97 +/// slice 3c) still goes through S3 regardless of runtime, so `aws_config` is +/// still needed for [`push_bundle`]; `context` is the kubeconfig context +/// [`crate::k8s_driver::K8sDriver::apply`] actually applies the Deployment +/// through. A k8s-provisioned agent's bundle upload and its Deployment apply +/// are two different systems with two different credentials — there's no way +/// to collapse this to a single config the way the ECS path's one +/// `aws_config::SdkConfig` covers both S3 and ECS. +pub async fn provision_k8s( + aws_config: &aws_config::SdkConfig, + context: Option<&str>, + manifest: &crate::manifest::OABServiceManifest, + objects: &[(String, Vec)], + control_plane_bucket: Option<&str>, +) -> Result { + // Same ordering as `provision()`: bundle first (idempotent puts), so the + // carrier is ready before the Deployment comes up and pre_seed runs. + push_bundle(aws_config, control_plane_bucket, objects).await?; + + let driver = crate::k8s_driver::K8sDriver::from_context(context).await?; + let opts = crate::driver::ProvisionOptions { + control_plane_bucket: control_plane_bucket.map(str::to_string), + wait: false, + }; + driver + .apply(std::slice::from_ref(manifest), &opts) + .await + .context("failed to apply k8s manifest during provision") +} + /// Load the desired `OABService` manifest oabctl persists at /// `manifests/{namespace}/{name}.yaml` in the control-plane bucket. Returns /// `Ok(None)` when the agent has no stored manifest yet (never applied); other diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 36bd194..835a68f 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -930,6 +930,149 @@ pub async fn provision_from_library( }) } +/// Extract the bare service-account name from an `expected_principal` string +/// in `system:serviceaccount::` form — the format +/// `K8sFleetBinding.expected_principal` holds when the "+ New fleet" wizard's +/// service-account picker set it (studio#104). `KubernetesRuntime. +/// service_account` wants just the bare name (`k8s_driver::build_deployment` +/// sets it as `pod_spec.service_account_name` directly), not the qualified +/// form `k8s_principal_kind` classifies against. `None` — or a plain +/// username, not a service account — means the pod uses the namespace's +/// default service account. +fn k8s_service_account_from_principal(expected_principal: Option<&str>) -> Option { + expected_principal + .and_then(|p| p.strip_prefix("system:serviceaccount:")) + .and_then(|rest| rest.split_once(':')) + .map(|(_namespace, name)| name.to_string()) +} + +/// Build a fresh k8s `OABServiceManifest` — the `Runtime::Kubernetes` +/// counterpart to [`build_default_manifest`]. No VPC/subnet/security-group +/// concept (that's ECS-specific networking); k8s's per-fleet placement is +/// `context` (which cluster) and `namespace` (both already resolved by the +/// caller from `K8sFleetBinding`, not part of the manifest itself — mirrors +/// how AWS's `cluster` is a driver-construction parameter, not a manifest +/// field). `node_selector`/`tolerations` default empty; `service_account` +/// comes from `expected_principal` when it names one, else the namespace's +/// default applies (same "unset = use default" contract `default_security_group`- +/// style AWS defaults don't have an equivalent of, since k8s already has one +/// built in). +async fn build_default_k8s_manifest( + namespace: &str, + name: &str, + image: &str, + bucket: &str, + expected_principal: Option<&str>, +) -> anyhow::Result { + let config_from = default_config_from_uri(bucket, namespace, name); + Ok(oabctl::manifest::OABServiceManifest { + api_version: "oab.dev/v2".to_string(), + kind: "OABService".to_string(), + metadata: oabctl::manifest::Metadata { + name: name.to_string(), + namespace: namespace.to_string(), + generation: 0, + }, + spec: oabctl::manifest::Spec { + image: image.to_string(), + resources: oabctl::manifest::Resources { + cpu: "256".to_string(), + memory: "512".to_string(), + }, + config_from, + bundle_from: None, + bootstrap_from: None, + secrets: std::collections::HashMap::new(), + runtime: oabctl::manifest::Runtime::Kubernetes(oabctl::manifest::KubernetesRuntime { + node_selector: std::collections::HashMap::new(), + service_account: k8s_service_account_from_principal(expected_principal), + tolerations: Vec::new(), + }), + ingress: None, + }, + }) +} + +/// [`provision_from_library`], but for a k8s-driven fleet — studio#104's +/// `deploy_provision` k8s dispatch, resumed once #111 gave both drivers a +/// shared, provider-agnostic create-vs-redeploy branch point +/// (`load_manifest` is just an S3 key, it doesn't care which `Runtime` variant +/// the stored manifest holds). +/// +/// Compose/bundle-upload is identical to the AWS path (the bundle carrier — +/// `hooks.pre_seed`, studio#97 slice 3c — is provider-agnostic, still S3 +/// regardless of where the agent actually runs) — some duplication with +/// `provision_from_library` here is deliberate for now rather than risk +/// reworking that already-landed function's shape again; worth revisiting +/// once both paths are proven. +/// +/// `expected_principal` is `K8sFleetBinding.expected_principal` — see +/// [`k8s_service_account_from_principal`] for how it maps onto the manifest. +pub async fn provision_from_library_k8s( + aws_config: &aws_config::SdkConfig, + context: Option<&str>, + namespace: &str, + name: &str, + library: &Library, + template: &str, + overlay: Option<&str>, + image_override: Option<&str>, + expected_principal: Option<&str>, +) -> anyhow::Result { + let mut bundle = studio_compose::compose_named(library, template, overlay) + .map_err(|e| anyhow::anyhow!("compose failed: {e}"))?; + let image = image_override + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| bundle.image_tag.clone()); + + let bucket = oabctl::resolve_bucket(aws_config, None).await?; + let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name); + match bundle.files.get_mut("config.toml") { + Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?, + None => anyhow::bail!("composed bundle for {namespace}/{name} has no config.toml — cannot wire hooks.pre_seed"), + } + + let mut objects = bundle.artifact_objects(namespace, name); + let zip_key = format!( + "{}/{}", + studio_compose::artifacts_prefix(namespace, name), + oabctl::studio_api::BUNDLE_ZIP_FILENAME + ); + objects.push((zip_key, bundle.zip_bytes())); + + let digest = bundle.digest(); + + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let mut manifest = match existing_manifest { + // Redeploy: preserve everything else about the stored manifest + // (including its k8s runtime config), only bump the image — same + // guarantee the AWS path's `redeploy()` gives. + Some(mut stored) => { + stored.spec.image = image.clone(); + stored + } + None => build_default_k8s_manifest(namespace, name, &image, &bucket, expected_principal).await?, + }; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + + let report = + oabctl::studio_api::provision_k8s(aws_config, context, &manifest, &objects, Some(&bucket)).await?; + + Ok(ProvisionOutcome { + image, + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + /// Scale an OAB service to `size` replicas (0 = off, 1 = on). /// /// Config-free: `cluster` / `namespace` are explicit (service = `oab-{namespace}-{name}`). @@ -1374,4 +1517,20 @@ namespace = "prod" "s3://oab-control-plane-123/artifacts/prod/orca/config.toml", ); } + + #[test] + fn k8s_service_account_from_principal_extracts_bare_name() { + assert_eq!( + k8s_service_account_from_principal(Some("system:serviceaccount:dev:oab-agent")), + Some("oab-agent".to_string()), + ); + } + + #[test] + fn k8s_service_account_from_principal_none_for_plain_username() { + // A plain username (not a service account) or unset both mean "use + // the namespace's default service account" — not an error. + assert_eq!(k8s_service_account_from_principal(Some("brett.chien")), None); + assert_eq!(k8s_service_account_from_principal(None), None); + } } From a60f49b021d3924b600bd0cfcc818b133a502649 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 01:06:22 +0800 Subject: [PATCH 2/2] feat(oab-mcp): wire deploy_provision's k8s provider dispatch (studio#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t_provision now branches on a new provider arg (default "aws", unchanged behavior): "k8s" calls provision_from_library_k8s (#114) with context/ expected_principal taken as direct args, not resolved via a fleet name — the console's identity form already collects context/namespace/service- account directly (#108/#109), and for a brand-new fleet there's no existing K8sFleetBinding to look up anyway. Fleet-scoped k8s lookup (for redeploying into an already-known k8s fleet) is left as explicit future work, not needed for this dispatch to exist. Also updates deploy_provision's tool description, which was stale after #113 (redeploy no longer requires an existing manifest) and needed the new provider/context/expected_principal params documented. NOTE — branch lineage: this stack (#112→#113→#114→this) was cut from `main` before #104's original stack (#105-110) merged, not from #110 — so K8sFleetBinding.expected_principal (originally #107) is re-added here too. Identical field in both places; trivial merge conflict to resolve whenever both land, flagging explicitly rather than silently duplicating without a note. With this, deploy_provision fully supports k8s end-to-end (provisioning side) — the remaining piece for full onboarding is unblocking console's k8s "Next" button (#108/#109's placeholder), a separate follow-up. Ref: studio#104. --- crates/oab-mcp/src/lib.rs | 49 +++++++++++++++++++++++++++++++++---- crates/studio-cp/src/lib.rs | 13 ++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 8daa59a..dba7178 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -146,7 +146,7 @@ pub fn tools() -> Vec { ), Tool::new( "deploy_provision", - "Provision an agent from the compose library: compose template ⊕ overlay into a file bundle, push it to the agent's S3 artifacts prefix, and redeploy the ECS service at the chosen image tag. Reuses the agent's stored manifest for networking/resources/secrets, so the agent must already have been created.", + "Provision an agent from the compose library: compose template ⊕ overlay into a file bundle and push it to the agent's S3 artifacts prefix (bundle carrier — shared regardless of provider). If the agent already has a stored manifest, patches its image/bundle and re-applies (networking/resources/secrets/runtime ride along unchanged). If not, builds a fresh manifest with sensible defaults and creates the agent — this now works for a genuinely brand-new agent, not just a redeploy of one already created via `oabctl create`. `provider` (default \"aws\") selects the target: \"aws\" applies via ECS (`fleet`/`cluster` select the credential); \"k8s\" applies via the given kubeconfig `context` instead, with `expected_principal` optionally naming a service account (`system:serviceaccount::` — the bare name becomes the pod's serviceAccountName; unset uses the namespace's default).", as_map(json!({ "type": "object", "properties": { @@ -156,8 +156,11 @@ pub fn tools() -> Vec { "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." }, "namespace": { "type": "string", "description": "Namespace (default \"default\")." }, "image_tag": { "type": "string", "description": "Image tag override (defaults to the bundle's own image tag)." }, - "fleet": { "type": "string", "description": "Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." }, - "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." } + "provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." }, + "fleet": { "type": "string", "description": "AWS only. Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." }, + "cluster": { "type": "string", "description": "AWS only. ECS cluster (defaults to the server's configured cluster)." }, + "context": { "type": "string", "description": "k8s only. Kubeconfig context to apply through. Omit to use the kubeconfig's current-context." }, + "expected_principal": { "type": "string", "description": "k8s only, optional. `system:serviceaccount::` to set the pod's service account; unset uses the namespace's default." } }, "required": ["library", "template", "name"] })), @@ -492,8 +495,6 @@ impl OabMcp { } async fn t_provision(&self, args: &Map) -> Result { - let t = self.target(args)?; - let cluster = t.cluster.clone(); let namespace = args .get("namespace") .and_then(Value::as_str) @@ -515,6 +516,44 @@ impl OabMcp { ) .map_err(|e| anyhow::anyhow!("invalid library: {e}"))?; + // studio#104: k8s dispatch. `context`/`expected_principal` come as + // direct args — the console's identity form already collects them + // (context/namespace/service-account