Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ pub fn tools() -> Vec<Tool> {
),
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:<ns>:<name>` — the bare name becomes the pod's serviceAccountName; unset uses the namespace's default).",
as_map(json!({
"type": "object",
"properties": {
Expand All @@ -156,8 +156,11 @@ pub fn tools() -> Vec<Tool> {
"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:<namespace>:<name>` to set the pod's service account; unset uses the namespace's default." }
},
"required": ["library", "template", "name"]
})),
Expand Down Expand Up @@ -492,8 +495,6 @@ impl OabMcp {
}

async fn t_provision(&self, args: &Map<String, Value>) -> Result<Value> {
let t = self.target(args)?;
let cluster = t.cluster.clone();
let namespace = args
.get("namespace")
.and_then(Value::as_str)
Expand All @@ -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 <select>s, #108/#109), there's
// no existing K8sFleetBinding to resolve them from for a brand-new
// fleet (unlike AWS's `fleet` + `self.target()` — a fleet-scoped k8s
// lookup is future work for redeploys into an *existing* k8s fleet,
// not needed for this dispatch to exist).
if args.get("provider").and_then(Value::as_str) == Some("k8s") {
let context = args.get("context").and_then(Value::as_str);
let expected_principal = args.get("expected_principal").and_then(Value::as_str);
let outcome = scp::provision_from_library_k8s(
&self.aws,
context,
namespace,
name,
&library,
template,
overlay,
image,
expected_principal,
)
.await?;
return Ok(json!({
"ok": true,
"context": context,
"namespace": namespace,
"name": name,
"image": outcome.image,
"digest": outcome.digest,
"objects": outcome.objects,
"action": outcome.action,
"services_applied": outcome.services_applied,
}));
}

let t = self.target(args)?;
let cluster = t.cluster.clone();

// Same fleet-scope guard as scale/delete: a fleet handle only provisions
// its own members, so a scoped call can't reach a co-located non-member.
let service_name = format!("oab-{namespace}-{name}");
Expand Down
37 changes: 37 additions & 0 deletions crates/oabctl/src/studio_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>)],
control_plane_bucket: Option<&str>,
) -> Result<crate::apply::ApplyReport> {
// 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
Expand Down
172 changes: 172 additions & 0 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,14 @@ pub struct K8sFleetBinding {
/// `FleetBinding`'s empty-members-means-everything convention).
#[serde(default)]
pub members: Vec<String>,
/// Expected principal to verify the resolved k8s identity against —
/// same name/semantics as `FleetBinding::expected_principal` for AWS.
/// **Duplicated from #107** (this branch was cut from `main` before
/// #107 merged, not from #107's own branch — see this PR's description
/// for the reconciliation note) — identical field, will be a trivial
/// merge conflict to resolve whenever both land.
#[serde(default)]
pub expected_principal: Option<String>,
}

impl K8sFleetBinding {
Expand All @@ -593,6 +601,8 @@ struct K8sFleetBody {
namespace: String,
#[serde(default)]
members: Vec<String>,
#[serde(default)]
expected_principal: Option<String>,
}

#[derive(serde::Deserialize)]
Expand All @@ -612,6 +622,7 @@ impl From<K8sFleetsDoc> for K8sFleetBindings {
context: b.context,
namespace: b.namespace,
members: b.members,
expected_principal: b.expected_principal,
})
.collect(),
}
Expand Down Expand Up @@ -930,6 +941,149 @@ pub async fn provision_from_library(
})
}

/// Extract the bare service-account name from an `expected_principal` string
/// in `system:serviceaccount:<namespace>:<name>` 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<String> {
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<oabctl::manifest::OABServiceManifest> {
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<ProvisionOutcome> {
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}`).
Expand Down Expand Up @@ -1239,6 +1393,7 @@ namespace = "prod"
context: Some("orbstack".into()),
namespace: "dev".into(),
members: vec!["scratch-agent".into()],
expected_principal: None,
};
assert!(scoped.includes("scratch-agent"));
assert!(!scoped.includes("other-agent"));
Expand All @@ -1248,6 +1403,7 @@ namespace = "prod"
context: None,
namespace: "prod".into(),
members: vec![],
expected_principal: None,
};
assert!(whole.includes("anything"));
}
Expand Down Expand Up @@ -1374,4 +1530,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);
}
}
Loading