Skip to main content
Glama
attributes.rs31.4 kB
use std::{ collections::HashMap, result, }; use serde::{ Deserialize, Serialize, }; use si_events::audit_log::{ AuditLogKind, PropValueSource, }; use si_id::{ AttributeValueId, ComponentId, FuncId, }; use telemetry::prelude::*; extern crate tuple_vec_map; use super::{ path::AttributePath, prototype::argument::{ AttributePrototypeArgument, static_value::StaticArgumentValue, }, value::subscription::ValueSubscription, }; use crate::{ AttributePrototype, AttributeValue, Component, DalContext, Func, Prop, PropKind, WsEvent, component::resource::ResourceData, func::intrinsics::IntrinsicFunc, prop::PropError, workspace_snapshot::node_weight::{ NodeWeight, reason_node_weight::Reason, }, }; pub type Result<T> = result::Result<T, AttributesError>; #[remain::sorted] #[derive(thiserror::Error, Debug)] pub enum AttributesError { #[error("attribute prototype error: {0}")] AttributePrototype(#[from] super::prototype::AttributePrototypeError), #[error("attribute prototype argument error: {0}")] AttributePrototypeArgument(#[from] super::prototype::argument::AttributePrototypeArgumentError), #[error("attribute $source: {0} has extra fields: {1}")] AttributeSourceHasExtraFields(serde_json::Value, serde_json::Value), #[error("invalid attribute $source: {0}")] AttributeSourceInvalid(serde_json::Value), #[error("attribute value error: {0}")] AttributeValue(#[from] crate::attribute::value::AttributeValueError), #[error("attribute value {0} not from component {1}")] AttributeValueNotFromComponent(AttributeValueId, ComponentId), #[error( "cannot update create-only property at path '{0}' when component has a resource attached" )] CannotUpdateCreateOnlyProperty(String), #[error("component error: {0}")] Component(#[from] crate::ComponentError), #[error("func error: {0}")] Func(#[from] crate::FuncError), #[error("prop error: {0}")] Prop(#[from] PropError), #[error("serde json error: {0}")] SerdeJson(#[from] serde_json::Error), #[error("source component not found: {0}")] SourceComponentNotFound(String), #[error("transactions error: {0}")] Transactions(#[from] crate::TransactionsError), #[error("workspace snapshot error: {0}")] WorkspaceSnapshot(#[from] crate::WorkspaceSnapshotError), #[error("ws event error: {0}")] WsEvent(#[from] crate::WsEventError), } #[derive(Debug)] pub struct AttributeUpdateCounts { pub set_count: usize, pub unset_count: usize, pub subscription_count: usize, } /// A set of attributes you want to set, with the values you want to set them to. /// /// Ensures that users cannot manually update restricted prop trees (secrets, resource_value, quals, etc.) /// /// - SET constant attribute values by putting the path to the attribute you want to set as the key, /// and the value you want to set it to on the right. /// NOTE: This will also enqueue update actions for the component if the preconditions are met /// /// { /// "/si/name": "Baby's First Subnet", /// "/domain/IpAddresses/0": "10.0.0.1", /// "/domain/Tags/Environment": "production", /// "/domain/DomainConfig/blah.com/TTL": 3600 /// } /// /// - REPLACE objects/arrays/maps: of special note, if you set an entire array, map or object, /// it *replaces* its value, and all existing keys are removed or unset. Another way of saying /// it: after you do this, the attribute on the left will be exactly equal to the value /// on the right, nothing more, nothing less. /// /// { /// "/domain/Tags": { "Environment": "production" }, /// "/domain/IpAddresses": [ "10.0.0.1", "10.0.0.2" ], /// "/domain/DomainConfig/blah.com": { "TTL": 3600 }, /// "/domain": { "IpAddresses": [ "10.0.0.1" ] } /// } /// /// - APPEND to array using `-` (or by setting the n+1'th element). If you set an array element /// that doesn't exist yet, it will be created. `-` is a special syntax for "add a new array /// element with this value," that doesn't require you to know the (the drawback being you /// can't append multiple elements to the same array in one API using `-`). /// /// It is an error to create an array element too far off the end of the array, but you can /// specify multiple separate elements in order if you want. (It is probably easier to replace /// the whole array in that case.) /// /// { /// "/domain/IpAddresses/0": "10.0.0.0", /// "/domain/IpAddresses/1": "10.0.0.1", /// "/domain/IpAddresses/2": "10.0.0.2", /// "/domain/IpAddresses/-": "10.0.0.3" /// } /// /// - INSERT to map by setting its value: if you set a map element that hasn't been created yet, /// it will be created. This will also happen if you set a *field* in a map element that doesn't exist yet (i.e. a /// map element with object values). /// /// { /// "/domain/Tags/Environment": "production", /// "/domain/DomainConfig/blah.com/TTL": 3600 /// } /// /// - UNSET a value using `{ "$source": null }`. The value will revert to using its default value. /// (NOTE: `{ "$source": {} }` unsets the value as well, allowing JS callers to construct the /// API call using `{ "$source": { value: myValueVariable } }``. If myValue is undefined, it /// will unset the value, but if it is null, it will set the value to null. /// /// { /// "/domain/Timeout": { "$source": null }, /// "/domain/DomainConfig/blah.com/TTL": { "$source": "value" } /// } /// /// - REMOVE an array or map element: unsetting an array or map element will remove it from the /// array or map. The remaining elements will shift over (it won't "leave a hole"). /// /// *Of note: if you want to remove multiple specific array elements, you should pass them in /// reverse order.* /// /// { /// "/domain/Tags/Environment": { "$source": null }, /// "/domain/IpAddresses/2": { "$source": null }, /// "/domain/IpAddresses/1": { "$source": null } /// } /// /// - SUBSCRIBE to another attribute's value: this will cause the value to always equal another /// attribute's value. Components may be specified by their name (which must be globally unique) /// or ComponentId. /// /// { /// "/domain/SubnetId": { /// "$source": { "component": "ComponentNameOrId", "path": "/resource/SubnetId" } /// } /// } /// /// You may specify a function ID to be used in subscription, to transform the value before setting /// it to the destination AV. /// /// If no func argument is passed, the func will be si:Identity. /// /// { /// "/domain/SubnetId": { /// "$source": { "component": "ComponentNameOrId", "path": "/resource/SubnetId", "func": "01JWBMRZAANBHKD2G2S5PZQTMA" } /// } /// } /// /// - ESCAPE HATCH for setting a value: setting an attribute to `{ "$source": { "value": <value> } }` /// has the same behavior as all the above cases. The reason this exists is, if you happen to /// have an object with a "$source" key, the existing interface would treat that as an error. /// This allows you to set that value anyway. /// /// This is a safer way to "escape" values if you are writing code that sets values generically /// without knowing their types and can avoid misinterpreted instructions or possibly even /// avoid injection attacks. /// /// { /// "/domain/Tags": { /// "$source": { /// "value": { "$source": "ThisTagIsActuallyNamed_$source" } /// } /// } /// } /// pub async fn update_attributes( ctx: &DalContext, component_id: ComponentId, updates: AttributeSources, ) -> Result<AttributeUpdateCounts> { let updates_to_process = updates.validate_user_can_set_directly(); update_attributes_inner(ctx, component_id, updates_to_process).await } /// Simimlar to [`update_attributes`], only skips the validation entirely so we can expose functionality to management operations /// but not our APIs/users pub async fn update_attributes_without_validation( ctx: &DalContext, component_id: ComponentId, updates: AttributeSources, ) -> Result<AttributeUpdateCounts> { update_attributes_inner(ctx, component_id, updates).await } /// Helper function to check if a prop has the "si_create_only_prop" widget option fn is_create_only_prop(prop: &Prop) -> bool { if let Some(widget_options) = &prop.widget_options { widget_options .iter() .any(|option| option.label() == "si_create_only_prop") } else { false } } async fn get_before_prop_value_source( ctx: &DalContext, av_id: AttributeValueId, ) -> Result<Option<PropValueSource>> { let prototype_id = AttributeValue::prototype_id(ctx, av_id).await?; let args = AttributePrototype::list_arguments(ctx, prototype_id).await?; let mut prop_value_sources = Vec::with_capacity(args.len()); for arg_id in args { let source = AttributePrototypeArgument::value_source(ctx, arg_id).await?; let prop_source = match source { super::prototype::argument::value_source::ValueSource::StaticArgumentValue( static_argument_value_id, ) => { let static_argument = StaticArgumentValue::get_by_id(ctx, static_argument_value_id) .await? .value; PropValueSource::Value(serde_json::to_value(static_argument)?) } super::prototype::argument::value_source::ValueSource::ValueSubscription( value_subscription, ) => match value_subscription.resolve(ctx).await? { Some(resolved_av_id) => { let source_component_id = AttributeValue::component_id(ctx, resolved_av_id).await?; let value = AttributeValue::view(ctx, resolved_av_id).await?; PropValueSource::Subscription { value, source_component_id, source_path: value_subscription.path.to_string(), } } _ => PropValueSource::None, }, _ => PropValueSource::None, }; prop_value_sources.push(prop_source); } if prop_value_sources.len() > 1 || prop_value_sources.is_empty() { // TODO: We need to handle what happens if there's no source or multiple sources return Ok(None); } let source = prop_value_sources.into_iter().next(); Ok(source) } async fn update_attributes_inner( ctx: &DalContext, component_id: ComponentId, updates: AttributeSources, ) -> Result<AttributeUpdateCounts> { let mut counts = AttributeUpdateCounts { set_count: 0, unset_count: 0, subscription_count: 0, }; for (av_to_set, value) in updates { match value.try_into()? { Some(value) => { counts.set_count += 1; // Create the attribute at the given path if it does not exist let path = av_to_set.path(); let target_av_id = av_to_set.clone().vivify(ctx, component_id).await?; // Check if this is a create-only property and component has a resource let prop_id = AttributeValue::prop_id(ctx, target_av_id).await?; let prop = Prop::get_by_id(ctx, prop_id).await?; if is_create_only_prop(&prop) && Component::resource_by_id(ctx, component_id) .await? .is_some() { return Err(AttributesError::CannotUpdateCreateOnlyProperty( path.to_string(), )); } let before_value_source = get_before_prop_value_source(ctx, target_av_id).await?; let mut after_value_source: Option<PropValueSource> = None; match value { Source::Value(value) => { // need to special case resource if path == "/resource" { let resource_data = ResourceData::new( veritech_client::ResourceStatus::Ok, Some(value.to_owned()), ); let component = Component::get_by_id(ctx, component_id).await?; component.set_resource(ctx, resource_data).await?; continue; } let kind = AttributeValue::prop_kind(ctx, target_av_id).await?; // If we are passed an object or array, to a JSON field, we JSON stringify it // for the user. let value = match (kind, &value) { ( PropKind::Json, serde_json::Value::Object(_) | serde_json::Value::Array(_), ) => serde_json::to_string_pretty(&value)?.into(), _ => value, }; let before_value = AttributeValue::get_by_id(ctx, target_av_id) .await? .unprocessed_value(ctx) .await?; AttributeValue::update(ctx, target_av_id, value.to_owned().into()).await?; if before_value.as_ref() != Some(&value) { // Build the Audit Log entry! after_value_source = Some(PropValueSource::Value(serde_json::to_value(value)?)); // If the values have changed then we should enqueue an update action // if the values haven't changed then we can skip this update action as it is usually a no-op Component::enqueue_update_action_if_applicable(ctx, target_av_id) .await?; } } Source::Subscription { component: source_component, path: source_path, func: func_ident, _keep_existing_subscriptions, } => { counts.subscription_count += 1; // First resolve the component_id (might be a name), then subscribe to the // given path let source_component_id = source_component .resolve(ctx) .await? .ok_or(AttributesError::SourceComponentNotFound(source_component.0))?; let subscription = ValueSubscription::new( ctx, source_component_id, AttributePath::from_json_pointer(source_path), ) .await?; let maybe_func_id = if let Some(func) = func_ident { func.resolve(ctx).await? } else { None }; // Subscribe! AttributeValue::set_to_subscription( ctx, target_av_id, subscription.clone(), maybe_func_id, Reason::new_user_added(ctx), ) .await?; // Build the after value for the audit log! after_value_source = match subscription.resolve(ctx).await? { Some(resolved_av_id) => { let source_component_id = AttributeValue::component_id(ctx, resolved_av_id).await?; let view = AttributeValue::view(ctx, resolved_av_id).await?; Some(PropValueSource::Subscription { value: view, source_component_id, source_path: subscription.path.to_string(), }) } _ => Some(PropValueSource::None), }; } } ctx.write_audit_log( AuditLogKind::SetAttribute { component_id, attribute_value_id: target_av_id, path: path.to_string(), before_value: before_value_source, after_value: after_value_source, }, path.to_string(), ) .await?; } None => { counts.unset_count += 1; // Unset or remove the value if it exists if let Some(target_av_id) = av_to_set.clone().resolve(ctx, component_id).await? { let before_value_source = get_before_prop_value_source(ctx, target_av_id).await?; let path = av_to_set.path(); AttributeValue::ensure_updateable(ctx, target_av_id).await?; if parent_prop_is_map_or_array(ctx, target_av_id).await? { // If the parent is a map or array, remove the value AttributeValue::remove(ctx, target_av_id).await?; } else { // Otherwise, use the default prototype (either by removing an existing // component prototype to revert to the schema variant prototype, or by // creating a si:Unset component prototype to explicitly unset the value) AttributeValue::use_default_prototype(ctx, target_av_id).await?; } ctx.write_audit_log( AuditLogKind::UnsetAttribute { component_id, attribute_value_id: target_av_id, path: path.to_string(), before_value: before_value_source, }, path.to_string(), ) .await?; } } } } // Notify the frontend about the updated attributes let component = Component::get_by_id(ctx, component_id).await?; let mut socket_map = HashMap::new(); let payload = component .into_frontend_type( ctx, None, component.change_status(ctx).await?, &mut socket_map, ) .await?; WsEvent::component_updated(ctx, payload) .await? .publish_on_commit(ctx) .await?; Ok(counts) } async fn parent_prop_is_map_or_array(ctx: &DalContext, av_id: AttributeValueId) -> Result<bool> { let Some(parent_av_id) = AttributeValue::parent_id(ctx, av_id).await? else { return Ok(false); }; let parent_prop_kind = AttributeValue::prop_kind(ctx, parent_av_id).await?; Ok(matches!(parent_prop_kind, PropKind::Map | PropKind::Array)) } /// A list of <path>: <source> pairs, used in attribute update APIs. /// Preserves order as well as duplicate paths (so you can use `-` multiple times). #[derive( Serialize, Deserialize, Clone, Debug, Default, derive_more::Deref, derive_more::Into, derive_more::IntoIterator, )] pub struct AttributeSources( // tuple_vec_map preserves order and allows duplicates #[serde(with = "tuple_vec_map")] pub Vec<(AttributeValueIdent, ValueOrSourceSpec)>, ); impl AttributeSources { pub fn is_empty(&self) -> bool { self.0.is_empty() } // We allow management functions to operate on the resource tree, but we // don't want to expose this to users pub fn validate_user_can_set_directly(self) -> Self { const IGNORE_PATHS: [&str; 4] = ["/code", "/deleted_at", "/qualification", "/resource_value"]; const ALLOW_WITH_SUBS_ONLY: [&str; 1] = ["/secrets"]; let mut filtered = Vec::new(); for (av_ident, value_or_source) in self.0 { let path = av_ident.path(); let should_ignore = IGNORE_PATHS .iter() .any(|ignore_path| path.starts_with(ignore_path)); if should_ignore { warn!( "Ignoring attempt to set value for restricted path: {}", path ); continue; } let is_subs_only = ALLOW_WITH_SUBS_ONLY .iter() .any(|sub_only_path| path.starts_with(sub_only_path)); if is_subs_only && !matches!(value_or_source, ValueOrSourceSpec::SourceSpec(_)) { warn!("Attempting to set a raw value for a secret: {path}"); continue; } filtered.push((av_ident, value_or_source)); } Self(filtered) } } /// The source for a value #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, derive_more::From)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub enum Source { // { value: <value> } - set value (null is a valid value to set it to) Value(serde_json::Value), // { component: "ComponentNameOrId", path: "/domain/Foo/Bar/0/Baz" } - subscribe this value to a path from a component #[serde(untagged, rename_all = "camelCase")] Subscription { component: ComponentIdent, path: String, #[serde(skip_serializing_if = "Option::is_none")] func: Option<FuncIdent>, // DEPRECATED and ignored. But kept here until we're sure callers are not using it #[serde( rename = "keep_existing_subscriptions", skip_serializing_if = "Option::is_none" )] _keep_existing_subscriptions: Option<bool>, }, } /// Either raw value or a { "$source": ... } spec (JSON for the source/value for an attribute) /// Use TryInto<Option<Source> to get Source out of it. If $source is set wrong, you will BadSourceSpecError. #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(untagged, rename = "camelCase")] pub enum ValueOrSourceSpec { /// Explicit sources: /// - static value: { "$source": { value: ... } } /// - subscription: { "$source": { component: "ComponentNameOrId", path: "/domain/Foo/Bar/0/Baz" } } /// - unset value: { "$source": null } or { "$source": {} } - unset value SourceSpec(SourceSpec), /// Catch errors: if it isn't a valid source, but has a "$source" field, treat it as an error /// so you get a 400 if you misuse the API BadSourceSpec { #[serde(rename = "$source")] source: serde_json::Value, #[serde(flatten)] extra: serde_json::Value, }, /// Any other JSON value is accepted and treated the same as { "$source": { "value": <value> } } RawValue(serde_json::Value), } /// { $source: <source> }. Separated from ValueOrSourceSpec so we could use deny_unknown_fields #[derive(Serialize, Deserialize, Clone, Debug, derive_more::From)] #[serde(rename = "camelCase", deny_unknown_fields)] pub struct SourceSpec { #[serde(rename = "$source")] source: MaybeSource, } /// Source or "unset" ({} or null). Used mainly for JSON deserialization. /// Use Into<Option<Source>> to get the real source. #[derive(Serialize, Deserialize, Clone, Debug, derive_more::From)] #[serde(untagged, rename = "camelCase", deny_unknown_fields)] enum MaybeSource { Source(Source), EmptyObject {}, Null, } impl From<MaybeSource> for Option<Source> { fn from(from: MaybeSource) -> Self { match from { MaybeSource::Source(source) => Some(source), MaybeSource::EmptyObject {} | MaybeSource::Null => None, } } } impl TryFrom<ValueOrSourceSpec> for Option<Source> { type Error = AttributesError; fn try_from(from: ValueOrSourceSpec) -> Result<Self> { match from { ValueOrSourceSpec::SourceSpec(SourceSpec { source }) => Ok(source.into()), ValueOrSourceSpec::BadSourceSpec { source, extra } => { if extra.as_object().is_none_or(|o| o.is_empty()) { Err(AttributesError::AttributeSourceInvalid(source)) } else { Err(AttributesError::AttributeSourceHasExtraFields( source, extra, )) } } ValueOrSourceSpec::RawValue(value) => Ok(Some(Source::Value(value))), } } } /// Convert from (path, source) to JS-capable AttributeSources /// (particularly, this will escape { <attr>: <value> } to { $source: { value: <value> } } if /// the value is an object with a $source key). impl<I: Into<AttributeValueIdent>, S: Into<ValueOrSourceSpec>> From<Vec<(I, S)>> for AttributeSources { fn from(pairs: Vec<(I, S)>) -> Self { AttributeSources( pairs .into_iter() .map(|(path, source)| (path.into(), source.into())) .collect(), ) } } impl From<Source> for ValueOrSourceSpec { fn from(source: Source) -> Self { match source { // If it's an object with $source as a key, "escape" it as { $source: <value> } Source::Value(value) => value.into(), Source::Subscription { .. } => ValueOrSourceSpec::SourceSpec(SourceSpec { source: MaybeSource::Source(source), }), } } } impl From<serde_json::Value> for ValueOrSourceSpec { fn from(value: serde_json::Value) -> Self { // If it's an object with $source as a key, "escape" it as { $source: <value> } if value.as_object().is_some_and(|o| o.contains_key("$source")) { return ValueOrSourceSpec::SourceSpec(SourceSpec { source: MaybeSource::Source(value.into()), }); } ValueOrSourceSpec::RawValue(value) } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, derive_more::From)] #[serde(rename_all = "camelCase")] pub struct ComponentIdent(String); impl From<ComponentId> for ComponentIdent { fn from(id: ComponentId) -> Self { Self(id.to_string()) } } impl From<ComponentIdent> for String { fn from(component_ident: ComponentIdent) -> Self { component_ident.0 } } impl ComponentIdent { pub async fn resolve(&self, ctx: &DalContext) -> Result<Option<ComponentId>> { if let Some(id) = self.resolve_as_id(ctx).await? { return Ok(Some(id)); } // Otherwise, try to find it by name Ok(Component::find_by_name(ctx, &self.0).await?) } async fn resolve_as_id(&self, ctx: &DalContext) -> Result<Option<ComponentId>> { // If it is not a ulid, we'll try the alternative let Ok(id) = self.0.parse() else { return Ok(None); }; let Some(NodeWeight::Component(_)) = ctx.workspace_snapshot()?.get_node_weight_opt(id).await else { return Ok(None); }; Ok(Some(id)) } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, derive_more::From)] #[serde(rename_all = "camelCase")] pub struct FuncIdent(String); impl From<FuncId> for FuncIdent { fn from(id: FuncId) -> Self { Self(id.to_string()) } } impl From<FuncIdent> for String { fn from(ident: FuncIdent) -> Self { ident.0 } } impl FuncIdent { #[allow(unused)] pub async fn resolve(&self, ctx: &DalContext) -> Result<Option<FuncId>> { if let Some(id) = self.resolve_as_id(ctx).await? { return Ok(Some(id)); } if let Some(func) = IntrinsicFunc::maybe_from_str(&self.0) { return Ok(Some(Func::find_intrinsic(ctx, func).await?)); } // Otherwise, try to find it by name Ok(None) } async fn resolve_as_id(&self, ctx: &DalContext) -> Result<Option<FuncId>> { let Ok(id) = self.0.parse() else { return Ok(None); }; let Some(NodeWeight::Func(_)) = ctx.workspace_snapshot()?.get_node_weight_opt(id).await else { return Ok(None); }; Ok(Some(id)) } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, derive_more::From)] #[serde(rename = "camelCase")] pub struct AttributeValueIdent(String); impl From<AttributeValueId> for AttributeValueIdent { fn from(id: AttributeValueId) -> Self { Self(id.to_string()) } } impl From<AttributeValueIdent> for String { fn from(ident: AttributeValueIdent) -> Self { ident.0 } } impl AttributeValueIdent { pub fn new(path: impl Into<String>) -> Self { Self(path.into()) } pub fn path(&self) -> &str { &self.0 } pub async fn resolve( self, ctx: &DalContext, component_id: ComponentId, ) -> Result<Option<AttributeValueId>> { if let Some(id) = self.resolve_as_id(ctx, component_id).await? { return Ok(Some(id)); } let root_id = Component::root_attribute_value_id(ctx, component_id).await?; let path = AttributePath::from_json_pointer(self.0); Ok(path.resolve(ctx, root_id).await?) } async fn vivify(self, ctx: &DalContext, component_id: ComponentId) -> Result<AttributeValueId> { if let Some(id) = self.resolve_as_id(ctx, component_id).await? { return Ok(id); } let root_id = Component::root_attribute_value_id(ctx, component_id).await?; let path = AttributePath::from_json_pointer(&self.0); Ok(path.vivify(ctx, root_id).await?) } async fn resolve_as_id( &self, ctx: &DalContext, component_id: ComponentId, ) -> Result<Option<AttributeValueId>> { // If it is not a ulid, we'll try the alternative let Ok(id) = self.0.parse() else { return Ok(None); }; // If it doesn't exist, we'll try the alternative if !ctx.workspace_snapshot()?.node_exists(id).await { return Ok(None); } // If it *does* exist but is from a different component or not from a component, // that is a hard error. if AttributeValue::component_id(ctx, id).await? != component_id { return Err(AttributesError::AttributeValueNotFromComponent( id, component_id, )); } Ok(Some(id)) } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server