feat: add runtime config bundle sync
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
use crate::catalog::ProfileSelector;
|
||||
use crate::error::RuntimeError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";
|
||||
|
||||
/// Backend-synced Profile/config bundle stored by a Runtime.
|
||||
///
|
||||
/// The bundle is intentionally an intent/declaration boundary: it contains
|
||||
/// profile selectors plus refs/grants/policies, never secret values, direct
|
||||
/// Runtime endpoints, raw socket/session paths, runtime-local mount actual
|
||||
/// paths, host-local cache paths, or fully resolved WorkerSpec content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigBundle {
|
||||
pub metadata: ConfigBundleMetadata,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub profiles: Vec<ConfigProfileDescriptor>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub declarations: Vec<ConfigDeclaration>,
|
||||
}
|
||||
|
||||
impl ConfigBundle {
|
||||
pub fn computed_digest(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("id\0{}", self.metadata.id));
|
||||
lines.push(format!("revision\0{}", self.metadata.revision));
|
||||
lines.push(format!("workspace_id\0{}", self.metadata.workspace_id));
|
||||
lines.push(format!("created_at\0{}", self.metadata.created_at));
|
||||
lines.push(format!(
|
||||
"provenance.source\0{}",
|
||||
self.metadata.provenance.source
|
||||
));
|
||||
lines.push(format!(
|
||||
"provenance.detail\0{}",
|
||||
self.metadata.provenance.detail.as_deref().unwrap_or("")
|
||||
));
|
||||
|
||||
let mut profiles = self.profiles.clone();
|
||||
profiles.sort_by(|left, right| {
|
||||
profile_sort_key(&left.selector).cmp(&profile_sort_key(&right.selector))
|
||||
});
|
||||
for profile in profiles {
|
||||
lines.push(format!(
|
||||
"profile\0{}\0{}",
|
||||
profile_sort_key(&profile.selector),
|
||||
profile.label.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let mut declarations = self.declarations.clone();
|
||||
declarations
|
||||
.sort_by(|left, right| declaration_sort_key(left).cmp(&declaration_sort_key(right)));
|
||||
for declaration in declarations {
|
||||
lines.push(format!(
|
||||
"declaration\0{}\0{}\0{}",
|
||||
declaration.kind.canonical_name(),
|
||||
declaration.name,
|
||||
declaration.reference
|
||||
));
|
||||
}
|
||||
|
||||
lines.sort();
|
||||
let mut hasher = Sha256::new();
|
||||
for line in lines {
|
||||
hasher.update(line.as_bytes());
|
||||
hasher.update(b"\n");
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
hex_digest(&digest)
|
||||
}
|
||||
|
||||
pub fn with_computed_digest(mut self) -> Self {
|
||||
self.metadata.digest = self.computed_digest();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> ConfigBundleSummary {
|
||||
ConfigBundleSummary {
|
||||
id: self.metadata.id.clone(),
|
||||
digest: self.metadata.digest.clone(),
|
||||
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
|
||||
revision: self.metadata.revision.clone(),
|
||||
workspace_id: self.metadata.workspace_id.clone(),
|
||||
created_at: self.metadata.created_at.clone(),
|
||||
provenance: self.metadata.provenance.clone(),
|
||||
profile_count: self.profiles.len(),
|
||||
declaration_count: self.declarations.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_profile(&self, selector: &ProfileSelector) -> bool {
|
||||
self.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.selector == *selector)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigBundleMetadata {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
pub revision: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
pub provenance: ConfigBundleProvenance,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigBundleProvenance {
|
||||
pub source: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigProfileDescriptor {
|
||||
pub selector: ProfileSelector,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigDeclaration {
|
||||
pub kind: ConfigDeclarationKind,
|
||||
pub name: String,
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigDeclarationKind {
|
||||
SecretRef,
|
||||
MountGrant,
|
||||
NetworkPolicy,
|
||||
ShellPolicy,
|
||||
GitPolicy,
|
||||
CapabilityGrant,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl ConfigDeclarationKind {
|
||||
pub fn canonical_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SecretRef => "secret_ref",
|
||||
Self::MountGrant => "mount_grant",
|
||||
Self::NetworkPolicy => "network_policy",
|
||||
Self::ShellPolicy => "shell_policy",
|
||||
Self::GitPolicy => "git_policy",
|
||||
Self::CapabilityGrant => "capability_grant",
|
||||
Self::Unsupported => "unsupported",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigBundleSummary {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
pub digest_algorithm: String,
|
||||
pub revision: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
pub provenance: ConfigBundleProvenance,
|
||||
pub profile_count: usize,
|
||||
pub declaration_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigBundleAvailability {
|
||||
pub reference: crate::catalog::ConfigBundleRef,
|
||||
pub summary: ConfigBundleSummary,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), RuntimeError> {
|
||||
validate_non_empty("config bundle id", &bundle.metadata.id)?;
|
||||
validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
|
||||
validate_non_empty("config bundle revision", &bundle.metadata.revision)?;
|
||||
validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?;
|
||||
validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
|
||||
validate_non_empty(
|
||||
"config bundle provenance source",
|
||||
&bundle.metadata.provenance.source,
|
||||
)?;
|
||||
validate_boundary_text("config bundle id", &bundle.metadata.id)?;
|
||||
validate_boundary_text("config bundle revision", &bundle.metadata.revision)?;
|
||||
validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?;
|
||||
validate_boundary_text(
|
||||
"config bundle provenance source",
|
||||
&bundle.metadata.provenance.source,
|
||||
)?;
|
||||
if let Some(detail) = &bundle.metadata.provenance.detail {
|
||||
validate_boundary_text("config bundle provenance detail", detail)?;
|
||||
}
|
||||
|
||||
let computed = bundle.computed_digest();
|
||||
if computed != bundle.metadata.digest {
|
||||
return Err(RuntimeError::ConfigBundleDigestMismatch {
|
||||
bundle_id: bundle.metadata.id.clone(),
|
||||
expected_digest: bundle.metadata.digest.clone(),
|
||||
actual_digest: computed,
|
||||
});
|
||||
}
|
||||
|
||||
for profile in &bundle.profiles {
|
||||
validate_profile_selector(profile.selector.clone(), Some(&bundle.metadata.id))?;
|
||||
if let Some(label) = &profile.label {
|
||||
validate_boundary_text("profile label", label)?;
|
||||
}
|
||||
}
|
||||
|
||||
for declaration in &bundle.declarations {
|
||||
validate_non_empty("config declaration name", &declaration.name)?;
|
||||
validate_non_empty("config declaration reference", &declaration.reference)?;
|
||||
validate_boundary_text("config declaration name", &declaration.name)?;
|
||||
validate_boundary_text("config declaration reference", &declaration.reference)?;
|
||||
if declaration.kind == ConfigDeclarationKind::Unsupported {
|
||||
return Err(RuntimeError::UnsupportedConfigDeclaration {
|
||||
bundle_id: bundle.metadata.id.clone(),
|
||||
declaration_kind: declaration.kind.canonical_name().to_string(),
|
||||
name: declaration.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_profile_selector(
|
||||
selector: ProfileSelector,
|
||||
bundle_id: Option<&str>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
match selector {
|
||||
ProfileSelector::RuntimeDefault => Ok(()),
|
||||
ProfileSelector::Builtin(value) | ProfileSelector::Named(value) => {
|
||||
if value.trim().is_empty() {
|
||||
Err(RuntimeError::InvalidProfileSelector {
|
||||
profile: value,
|
||||
bundle_id: bundle_id.map(ToOwned::to_owned),
|
||||
message: "profile selector must not be empty".to_string(),
|
||||
})
|
||||
} else {
|
||||
validate_boundary_text("profile selector", &value).map_err(|err| match err {
|
||||
RuntimeError::InvalidRequest(message) => RuntimeError::InvalidProfileSelector {
|
||||
profile: value,
|
||||
bundle_id: bundle_id.map(ToOwned::to_owned),
|
||||
message,
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_non_empty(label: &'static str, value: &str) -> Result<(), RuntimeError> {
|
||||
if value.trim().is_empty() {
|
||||
Err(RuntimeError::InvalidRequest(format!(
|
||||
"{label} must not be empty"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_boundary_text(label: &'static str, value: &str) -> Result<(), RuntimeError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.len() > 2048 {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"{label} is too large"
|
||||
)));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"{label} must not contain control characters"
|
||||
)));
|
||||
}
|
||||
if Path::new(trimmed).is_absolute()
|
||||
|| trimmed.starts_with('~')
|
||||
|| trimmed.contains("/.cache")
|
||||
|| trimmed.contains("\\.cache")
|
||||
|| trimmed.contains("/run/")
|
||||
|| trimmed.contains("\\run\\")
|
||||
|| trimmed.contains(".sock")
|
||||
|| trimmed.contains("socket=")
|
||||
|| trimmed.contains("session_path")
|
||||
|| trimmed.contains("cache_path")
|
||||
{
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"{label} must be a stable ref/grant/policy declaration, not a host-local path"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn declaration_sort_key(declaration: &ConfigDeclaration) -> String {
|
||||
format!(
|
||||
"{}\0{}\0{}",
|
||||
declaration.kind.canonical_name(),
|
||||
declaration.name,
|
||||
declaration.reference
|
||||
)
|
||||
}
|
||||
|
||||
fn profile_sort_key(selector: &ProfileSelector) -> String {
|
||||
match selector {
|
||||
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
|
||||
ProfileSelector::Builtin(value) => format!("builtin\0{value}"),
|
||||
ProfileSelector::Named(value) => format!("named\0{value}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user