-
Notifications
You must be signed in to change notification settings - Fork 60
[subtask] [Subtask 3/5] Provisioning Controller: Implement manifest-driven component loading #541
Description
Parent Issue: #307
Objective
Implement the provisioning controller that reads a manifest file, validates it, fetches components, verifies integrity, and materializes policies during startup.
Context
This is the core of headless deployment mode. The ProvisioningController orchestrates the declarative startup sequence, ensuring all components and policies are provisioned before the MCP server begins accepting requests. This replaces the interactive "load component at runtime" workflow with a fail-fast, auditable bootstrap process.
Implementation Details
1. Create Manifest Parser
File: crates/wassette/src/manifest.rs
Add parsing function:
use anyhow::{Context, Result};
use std::path::Path;
use std::fs;
impl ProvisioningManifest {
/// Parse manifest from YAML or TOML file
pub fn from_file(path: &Path) -> Result(Self) {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read manifest: {}", path.display()))?;
// Try YAML first, then TOML
if path.extension().map_or(false, |ext| ext == "yaml" || ext == "yml") {
serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML manifest: {}", path.display()))
} else if path.extension().map_or(false, |ext| ext == "toml") {
toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML manifest: {}", path.display()))
} else {
// Auto-detect: try YAML first, fall back to TOML
serde_yaml::from_str(&content)
.or_else(|_| toml::from_str(&content))
.with_context(|| format!("Failed to parse manifest as YAML or TOML: {}", path.display()))
}
}
/// Validate manifest for consistency and completeness
pub fn validate(&self) -> Result<()> {
// Check version
if self.version != 1 {
anyhow::bail!("Unsupported manifest version: {}. Expected version 1.", self.version);
}
// Check for duplicate URIs
let mut seen_uris = std::collections::HashSet::new();
for component in &self.components {
if !seen_uris.insert(&component.uri) {
anyhow::bail!("Duplicate component URI in manifest: {}", component.uri);
}
}
// Validate permission configs
for (idx, component) in self.components.iter().enumerate() {
match &component.permissions {
PermissionConfig::File { policy } => {
if !policy.exists() {
anyhow::bail!(
"Component {} references non-existent policy file: {}",
idx, policy.display()
);
}
}
PermissionConfig::Inline { .. } => {
// Inline permissions validated during policy synthesis
}
}
// Validate retry policy if present
if let Some(ref retry) = component.retry_policy {
if retry.attempts == 0 {
anyhow::bail!("Component {} has invalid retry_policy.attempts: must be > 0", idx);
}
}
}
Ok(())
}
}Add dependencies to crates/wassette/Cargo.toml:
serde_yaml = "0.9"
toml = "0.8"2. Create ProvisioningController
New File: crates/wassette/src/provisioning.rs
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//! Provisioning controller for headless deployments
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use tracing::{info, warn, debug};
use crate::{
LifecycleManager,
manifest::{ProvisioningManifest, ComponentEntry, PermissionConfig, BackoffStrategy},
loader::ResourceUri,
};
pub struct ProvisioningController {
manifest: ProvisioningManifest,
}
impl ProvisioningController {
pub fn new(manifest: ProvisioningManifest) -> Self {
Self { manifest }
}
/// Run the full provisioning sequence
pub async fn provision(&self, lifecycle: &LifecycleManager) -> Result<()> {
info!(
component_count = self.manifest.components.len(),
"Starting headless provisioning"
);
for (idx, component_entry) in self.manifest.components.iter().enumerate() {
info!(
uri = %component_entry.uri,
component_index = idx + 1,
total_components = self.manifest.components.len(),
"Provisioning component"
);
self.provision_component(lifecycle, component_entry)
.await
.with_context(|| {
format!("Failed to provision component {}: {}", idx + 1, component_entry.uri)
})?;
}
info!("Headless provisioning completed successfully");
Ok(())
}
async fn provision_component(
&self,
lifecycle: &LifecycleManager,
entry: &ComponentEntry,
) -> Result<()> {
// Step 1: Fetch component with retry logic
let component_data = self.fetch_with_retry(lifecycle, &entry.uri, entry.retry_policy.as_ref())
.await
.with_context(|| format!("Failed to fetch component: {}", entry.uri))?;
// Step 2: Verify digest if provided
if let Some(ref expected_digest) = entry.digest {
self.verify_digest(&component_data, expected_digest)?;
}
// Step 3: Load component into lifecycle manager
let component_id = lifecycle.load_component_from_bytes(&entry.uri, component_data)
.await
.with_context(|| format!("Failed to load component: {}", entry.uri))?;
debug!(component_id = %component_id, "Component loaded");
// Step 4: Materialize permissions
self.materialize_permissions(lifecycle, &component_id, &entry.permissions)
.await
.with_context(|| format!("Failed to materialize permissions for component: {}", component_id))?;
info!(component_id = %component_id, uri = %entry.uri, "Component provisioned successfully");
Ok(())
}
async fn fetch_with_retry(
&self,
lifecycle: &LifecycleManager,
uri: &str,
retry_policy: Option<&crate::manifest::RetryPolicy>,
) -> Result(Vec<u8)> {
let max_attempts = retry_policy.map(|p| p.attempts).unwrap_or(1);
for attempt in 1..=max_attempts {
match lifecycle.fetch_component_bytes(uri).await {
Ok(data) => {
debug!(uri = %uri, attempt, "Component fetched successfully");
return Ok(data);
}
Err(e) if attempt < max_attempts => {
warn!(
uri = %uri,
attempt,
max_attempts,
error = %e,
"Component fetch failed, retrying..."
);
if let Some(ref policy) = retry_policy {
let delay_ms = match &policy.backoff {
BackoffStrategy::Exponential { base_ms } => {
base_ms * 2u64.pow(attempt - 1)
}
BackoffStrategy::Linear { increment_ms } => {
increment_ms * (attempt as u64)
}
};
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
}
Err(e) => {
return Err(e).with_context(|| {
format!("Failed to fetch component after {} attempts", max_attempts)
});
}
}
}
unreachable!()
}
fn verify_digest(&self, data: &[u8], expected: &str) -> Result<()> {
use sha2::{Sha256, Digest};
// Parse expected format: "sha256:hexstring"
let expected = expected.strip_prefix("sha256:")
.ok_or_else(|| anyhow::anyhow!("Digest must start with 'sha256:'"))?;
let mut hasher = Sha256::new();
hasher.update(data);
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
anyhow::bail!(
"Digest mismatch: expected sha256:{}, got sha256:{}",
expected,
actual
);
}
debug!("Digest verification passed");
Ok(())
}
async fn materialize_permissions(
&self,
lifecycle: &LifecycleManager,
component_id: &str,
permission_config: &PermissionConfig,
) -> Result<()> {
match permission_config {
PermissionConfig::File { policy } => {
// Copy policy file to component directory
lifecycle.install_policy_file(component_id, policy)
.await
.with_context(|| format!("Failed to install policy file: {}", policy.display()))?;
debug!(component_id, policy_file = %policy.display(), "Policy file installed");
}
PermissionConfig::Inline { inline } => {
// Synthesize policy from inline permissions
lifecycle.synthesize_policy(component_id, inline)
.await
.with_context(|| "Failed to synthesize inline policy")?;
debug!(component_id, "Inline policy synthesized");
}
}
Ok(())
}
}Add to crates/wassette/Cargo.toml:
sha2 = "0.10"
tokio = { version = "1", features = ["time"] }3. Add Provisioning Methods to LifecycleManager
File: crates/wassette/src/lib.rs
Add helper methods that the provisioning controller needs:
impl LifecycleManager {
/// Fetch component bytes from URI (internal helper for provisioning)
pub(crate) async fn fetch_component_bytes(&self, uri: &str) -> Result(Vec<u8)> {
// Use existing loader infrastructure
let resource = crate::loader::load_resource(
uri,
&self.http_client,
&self.oci_client,
).await?;
Ok(resource.bytes().to_vec())
}
/// Load component from bytes with explicit URI
pub(crate) async fn load_component_from_bytes(
&self,
uri: &str,
bytes: Vec(u8),
) -> Result(String) {
// Leverage existing load_component logic but from bytes
// This will require refactoring load_component to accept either URI or bytes
todo!("Integrate with existing load_component infrastructure")
}
/// Install a policy file for a component
pub(crate) async fn install_policy_file(
&self,
component_id: &str,
policy_path: &Path,
) -> Result<()> {
// Copy policy file to component directory as {component_id}.policy.yaml
let dest = self.config.component_dir()
.join(format!("{}.policy.yaml", component_id));
std::fs::copy(policy_path, &dest)
.with_context(|| format!("Failed to copy policy file to {}", dest.display()))?;
// Reload policy into policy manager
self.policy_manager.reload_policy(component_id).await?;
Ok(())
}
/// Synthesize policy from inline permissions
pub(crate) async fn synthesize_policy(
&self,
component_id: &str,
inline_permissions: &crate::manifest::InlinePermissions,
) -> Result<()> {
// Convert inline permissions to policy YAML format
// Write to {component_id}.policy.yaml
// Seed secrets manager with environment variables
todo!("Implement inline policy synthesis")
}
}4. Integrate into Startup Sequence
File: crates/wassette/src/lib.rs
Update the LifecycleManager initialization to run provisioning when in headless mode:
// After building LifecycleManager
if config.profile() == &DeploymentProfile::Headless {
if let Some(manifest_path) = config.manifest_path() {
info!("Running headless provisioning from manifest: {}", manifest_path.display());
let manifest = ProvisioningManifest::from_file(manifest_path)?;
manifest.validate()?;
let controller = crate::provisioning::ProvisioningController::new(manifest);
controller.provision(&lifecycle_manager).await
.context("Headless provisioning failed")?;
} else {
anyhow::bail!("Headless profile requires manifest path");
}
}5. Add Module Declaration
File: crates/wassette/src/lib.rs
mod provisioning;Acceptance Criteria
- Manifest can be parsed from YAML and TOML files
- Manifest validation catches common errors (duplicate URIs, missing files, invalid values)
- Component fetching works with existing loader infrastructure
- Retry logic honors manifest retry_policy configuration
- SHA-256 digest verification works correctly
- Policy files can be copied to component directory
- Provisioning controller runs during headless startup
- All errors provide actionable context messages
-
cargo buildsucceeds -
cargo clippypasses with no warnings
Testing Strategy
Unit Tests
Add to crates/wassette/src/manifest.rs:
#[test]
fn test_validate_duplicate_uris() {
let manifest = ProvisioningManifest { /* duplicate URIs */ };
assert!(manifest.validate().is_err());
}
#[test]
fn test_validate_missing_policy_file() {
let manifest = ProvisioningManifest { /* non-existent policy */ };
assert!(manifest.validate().is_err());
}Add to crates/wassette/src/provisioning.rs:
#[test]
fn test_digest_verification() {
let data = b"test data";
let correct_digest = "sha256:916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9";
assert!(verify_digest(data, correct_digest).is_ok());
let wrong_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
assert!(verify_digest(data, wrong_digest).is_err());
}Integration Test
Create tests/headless_provisioning.rs:
#[tokio::test]
async fn test_headless_provisioning_flow() {
// Create test manifest
// Run provisioning
// Verify components loaded
// Verify policies installed
}Dependencies
- Subtask 1/5: Requires manifest schema types
- Subtask 2/5: Requires CLI integration for triggering
References
- Parent issue Proposal: Headless Deployment Mode for Wassette #307 sections "Provisioning Manifest" and "Startup Sequence Changes"
- Existing loader code in
crates/wassette/src/loader.rs - Existing policy code in
crates/wassette/src/policy_internal.rs
Related to Proposal: Headless Deployment Mode for Wassette #307