spin_runtime_config/
lib.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context as _;
4use spin_common::ui::quoted_path;
5use spin_factor_key_value::runtime_config::spin::{self as key_value};
6use spin_factor_key_value::KeyValueFactor;
7use spin_factor_llm::{spin as llm, LlmFactor};
8use spin_factor_outbound_http::OutboundHttpFactor;
9use spin_factor_outbound_mqtt::OutboundMqttFactor;
10use spin_factor_outbound_mysql::OutboundMysqlFactor;
11use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig as OutboundNetworkingSpinRuntimeConfig;
12use spin_factor_outbound_networking::OutboundNetworkingFactor;
13use spin_factor_outbound_pg::OutboundPgFactor;
14use spin_factor_outbound_redis::OutboundRedisFactor;
15use spin_factor_sqlite::SqliteFactor;
16use spin_factor_variables::VariablesFactor;
17use spin_factor_wasi::WasiFactor;
18use spin_factors::runtime_config::toml::GetTomlValue as _;
19use spin_factors::{
20    runtime_config::toml::TomlKeyTracker, FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer,
21};
22use spin_key_value_spin::{SpinKeyValueRuntimeConfig, SpinKeyValueStore};
23use spin_sqlite as sqlite;
24use spin_trigger::cli::UserProvidedPath;
25use toml::Value;
26
27/// The default state directory for the trigger.
28pub const DEFAULT_STATE_DIR: &str = ".spin";
29
30/// A runtime configuration which has been resolved from a runtime config source.
31///
32/// Includes other pieces of configuration that are used to resolve the runtime configuration.
33pub struct ResolvedRuntimeConfig<T> {
34    /// The resolved runtime configuration.
35    pub runtime_config: T,
36    /// The resolver used to resolve key-value stores from runtime configuration.
37    pub key_value_resolver: key_value::RuntimeConfigResolver,
38    /// The resolver used to resolve sqlite databases from runtime configuration.
39    pub sqlite_resolver: sqlite::RuntimeConfigResolver,
40    /// The fully resolved state directory.
41    ///
42    /// `None` is used for an "unset" state directory which each factor will treat differently.
43    pub state_dir: Option<PathBuf>,
44    /// The fully resolved log directory.
45    ///
46    /// `None` is used for an "unset" log directory.
47    pub log_dir: Option<PathBuf>,
48    /// The maximum memory allocation limit.
49    pub max_instance_memory: Option<usize>,
50    /// The input TOML, for informational summaries.
51    pub toml: toml::Table,
52}
53
54impl<T> ResolvedRuntimeConfig<T> {
55    pub fn summarize(&self, runtime_config_path: Option<&Path>) {
56        let summarize_labeled_typed_tables = |key| {
57            let mut summaries = vec![];
58            if let Some(tables) = self.toml.get(key).and_then(Value::as_table) {
59                for (label, config) in tables {
60                    if let Some(ty) = config.get("type").and_then(Value::as_str) {
61                        summaries.push(format!("[{key}.{label}: {ty}]"))
62                    }
63                }
64            }
65            summaries
66        };
67
68        let mut summaries = vec![];
69        // [key_value_store.<label>: <type>]
70        summaries.extend(summarize_labeled_typed_tables("key_value_store"));
71        // [sqlite_database.<label>: <type>]
72        summaries.extend(summarize_labeled_typed_tables("sqlite_database"));
73        // [llm_compute: <type>]
74        if let Some(table) = self.toml.get("llm_compute").and_then(Value::as_table) {
75            if let Some(ty) = table.get("type").and_then(Value::as_str) {
76                summaries.push(format!("[llm_compute: {ty}"));
77            }
78        }
79        if !summaries.is_empty() {
80            let summaries = summaries.join(", ");
81            let from_path = runtime_config_path
82                .map(|path| format!("from {}", quoted_path(path)))
83                .unwrap_or_default();
84            println!("Using runtime config {summaries} {from_path}");
85        }
86    }
87}
88
89impl<T> ResolvedRuntimeConfig<T>
90where
91    T: for<'a, 'b> TryFrom<TomlRuntimeConfigSource<'a, 'b>>,
92    for<'a, 'b> <T as TryFrom<TomlRuntimeConfigSource<'a, 'b>>>::Error: Into<anyhow::Error>,
93{
94    /// Creates a new resolved runtime configuration from a runtime config source TOML file.
95    ///
96    /// `provided_state_dir` is the explicitly provided state directory, if any.
97    pub fn from_file(
98        runtime_config_path: Option<&Path>,
99        local_app_dir: Option<PathBuf>,
100        provided_state_dir: UserProvidedPath,
101        provided_log_dir: UserProvidedPath,
102    ) -> anyhow::Result<Self> {
103        let toml = match runtime_config_path {
104            Some(runtime_config_path) => {
105                let file = std::fs::read_to_string(runtime_config_path).with_context(|| {
106                    format!(
107                        "failed to read runtime config file '{}'",
108                        runtime_config_path.display()
109                    )
110                })?;
111                toml::from_str(&file).with_context(|| {
112                    format!(
113                        "failed to parse runtime config file '{}' as toml",
114                        runtime_config_path.display()
115                    )
116                })?
117            }
118            None => Default::default(),
119        };
120        let toml_resolver =
121            TomlResolver::new(&toml, local_app_dir, provided_state_dir, provided_log_dir);
122
123        Self::new(toml_resolver, runtime_config_path)
124    }
125
126    /// Creates a new resolved runtime configuration from a TOML table.
127    pub fn new(
128        toml_resolver: TomlResolver<'_>,
129        runtime_config_path: Option<&Path>,
130    ) -> anyhow::Result<Self> {
131        let runtime_config_dir = runtime_config_path
132            .and_then(Path::parent)
133            .map(ToOwned::to_owned);
134        let state_dir = toml_resolver.state_dir()?;
135        let outbound_networking = runtime_config_dir
136            .clone()
137            .map(OutboundNetworkingSpinRuntimeConfig::new);
138        let key_value_resolver = key_value_config_resolver(runtime_config_dir, state_dir.clone());
139        let sqlite_resolver = sqlite_config_resolver(state_dir.clone())
140            .context("failed to resolve sqlite runtime config")?;
141
142        let toml = toml_resolver.toml();
143        let log_dir = toml_resolver.log_dir()?;
144        let max_instance_memory = toml_resolver.max_instance_memory()?;
145
146        let source = TomlRuntimeConfigSource::new(
147            toml_resolver,
148            &key_value_resolver,
149            outbound_networking.as_ref(),
150            &sqlite_resolver,
151        );
152
153        // Note: all valid fields in the runtime config must have been referenced at
154        // this point or the finalizer will fail due to `validate_all_keys_used`
155        // not passing.
156        let runtime_config: T = source.try_into().map_err(Into::into)?;
157
158        Ok(Self {
159            runtime_config,
160            key_value_resolver,
161            sqlite_resolver,
162            state_dir,
163            log_dir,
164            max_instance_memory,
165            toml,
166        })
167    }
168
169    /// The fully resolved state directory.
170    pub fn state_dir(&self) -> Option<PathBuf> {
171        self.state_dir.clone()
172    }
173
174    /// The fully resolved state directory.
175    pub fn log_dir(&self) -> Option<PathBuf> {
176        self.log_dir.clone()
177    }
178
179    /// The maximum memory allocation limit.
180    pub fn max_instance_memory(&self) -> Option<usize> {
181        self.max_instance_memory
182    }
183}
184
185#[derive(Clone, Debug)]
186/// Resolves runtime configuration from a TOML file.
187pub struct TomlResolver<'a> {
188    table: TomlKeyTracker<'a>,
189    /// The local app directory.
190    local_app_dir: Option<PathBuf>,
191    /// Explicitly provided state directory.
192    state_dir: UserProvidedPath,
193    /// Explicitly provided log directory.
194    log_dir: UserProvidedPath,
195}
196
197impl<'a> TomlResolver<'a> {
198    /// Create a new TOML resolver.
199    pub fn new(
200        table: &'a toml::Table,
201        local_app_dir: Option<PathBuf>,
202        state_dir: UserProvidedPath,
203        log_dir: UserProvidedPath,
204    ) -> Self {
205        Self {
206            table: TomlKeyTracker::new(table),
207            local_app_dir,
208            state_dir,
209            log_dir,
210        }
211    }
212
213    /// Get the configured state_directory.
214    ///
215    /// Errors if the path cannot be converted to an absolute path.
216    pub fn state_dir(&self) -> std::io::Result<Option<PathBuf>> {
217        let mut state_dir = self.state_dir.clone();
218        // If the state_dir is not explicitly provided, check the toml.
219        if matches!(state_dir, UserProvidedPath::Default) {
220            let from_toml =
221                self.table
222                    .get("state_dir")
223                    .and_then(|v| v.as_str())
224                    .map(|toml_value| {
225                        if toml_value.is_empty() {
226                            // If the toml value is empty, treat it as unset.
227                            UserProvidedPath::Unset
228                        } else {
229                            // Otherwise, treat the toml value as a provided path.
230                            UserProvidedPath::Provided(PathBuf::from(toml_value))
231                        }
232                    });
233            // If toml value is not provided, use the original value after all.
234            state_dir = from_toml.unwrap_or(state_dir);
235        }
236
237        match (state_dir, &self.local_app_dir) {
238            (UserProvidedPath::Provided(p), _) => Ok(Some(std::path::absolute(p)?)),
239            (UserProvidedPath::Default, Some(local_app_dir)) => {
240                Ok(Some(local_app_dir.join(".spin")))
241            }
242            (UserProvidedPath::Default | UserProvidedPath::Unset, _) => Ok(None),
243        }
244    }
245
246    /// Get the configured log directory.
247    ///
248    /// Errors if the path cannot be converted to an absolute path.
249    pub fn log_dir(&self) -> std::io::Result<Option<PathBuf>> {
250        let mut log_dir = self.log_dir.clone();
251        // If the log_dir is not explicitly provided, check the toml.
252        if matches!(log_dir, UserProvidedPath::Default) {
253            let from_toml = self
254                .table
255                .get("log_dir")
256                .and_then(|v| v.as_str())
257                .map(|toml_value| {
258                    if toml_value.is_empty() {
259                        // If the toml value is empty, treat it as unset.
260                        UserProvidedPath::Unset
261                    } else {
262                        // Otherwise, treat the toml value as a provided path.
263                        UserProvidedPath::Provided(PathBuf::from(toml_value))
264                    }
265                });
266            // If toml value is not provided, use the original value after all.
267            log_dir = from_toml.unwrap_or(log_dir);
268        }
269
270        match log_dir {
271            UserProvidedPath::Provided(p) => Ok(Some(std::path::absolute(p)?)),
272            UserProvidedPath::Default => Ok(self.state_dir()?.map(|p| p.join("logs"))),
273            UserProvidedPath::Unset => Ok(None),
274        }
275    }
276
277    /// Get the configured maximum memory allocation limit.
278    pub fn max_instance_memory(&self) -> anyhow::Result<Option<usize>> {
279        self.table
280            .get("max_instance_memory")
281            .and_then(|v| v.as_integer())
282            .map(|toml_value| toml_value.try_into())
283            .transpose()
284            .map_err(Into::into)
285    }
286
287    /// Validate that all keys in the TOML file have been used.
288    pub fn validate_all_keys_used(&self) -> spin_factors::Result<()> {
289        self.table.validate_all_keys_used()
290    }
291
292    fn toml(&self) -> toml::Table {
293        self.table.as_ref().clone()
294    }
295}
296
297/// The TOML based runtime configuration source Spin CLI.
298pub struct TomlRuntimeConfigSource<'a, 'b> {
299    toml: TomlResolver<'b>,
300    key_value: &'a key_value::RuntimeConfigResolver,
301    outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
302    sqlite: &'a sqlite::RuntimeConfigResolver,
303}
304
305impl<'a, 'b> TomlRuntimeConfigSource<'a, 'b> {
306    pub fn new(
307        toml_resolver: TomlResolver<'b>,
308        key_value: &'a key_value::RuntimeConfigResolver,
309        outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
310        sqlite: &'a sqlite::RuntimeConfigResolver,
311    ) -> Self {
312        Self {
313            toml: toml_resolver,
314            key_value,
315            outbound_networking,
316            sqlite,
317        }
318    }
319}
320
321impl FactorRuntimeConfigSource<KeyValueFactor> for TomlRuntimeConfigSource<'_, '_> {
322    fn get_runtime_config(
323        &mut self,
324    ) -> anyhow::Result<Option<spin_factor_key_value::RuntimeConfig>> {
325        Ok(Some(self.key_value.resolve(Some(&self.toml.table))?))
326    }
327}
328
329impl FactorRuntimeConfigSource<OutboundNetworkingFactor> for TomlRuntimeConfigSource<'_, '_> {
330    fn get_runtime_config(
331        &mut self,
332    ) -> anyhow::Result<Option<<OutboundNetworkingFactor as spin_factors::Factor>::RuntimeConfig>>
333    {
334        let Some(tls) = self.outbound_networking else {
335            return Ok(None);
336        };
337        tls.config_from_table(&self.toml.table)
338    }
339}
340
341impl FactorRuntimeConfigSource<VariablesFactor> for TomlRuntimeConfigSource<'_, '_> {
342    fn get_runtime_config(
343        &mut self,
344    ) -> anyhow::Result<Option<<VariablesFactor as spin_factors::Factor>::RuntimeConfig>> {
345        Ok(Some(spin_variables::runtime_config_from_toml(
346            &self.toml.table,
347        )?))
348    }
349}
350
351impl FactorRuntimeConfigSource<OutboundPgFactor> for TomlRuntimeConfigSource<'_, '_> {
352    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
353        Ok(None)
354    }
355}
356
357impl FactorRuntimeConfigSource<OutboundMysqlFactor> for TomlRuntimeConfigSource<'_, '_> {
358    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
359        Ok(None)
360    }
361}
362
363impl FactorRuntimeConfigSource<LlmFactor> for TomlRuntimeConfigSource<'_, '_> {
364    fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_llm::RuntimeConfig>> {
365        llm::runtime_config_from_toml(&self.toml.table, self.toml.state_dir()?)
366    }
367}
368
369impl FactorRuntimeConfigSource<OutboundRedisFactor> for TomlRuntimeConfigSource<'_, '_> {
370    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
371        Ok(None)
372    }
373}
374
375impl FactorRuntimeConfigSource<WasiFactor> for TomlRuntimeConfigSource<'_, '_> {
376    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
377        Ok(None)
378    }
379}
380
381impl FactorRuntimeConfigSource<OutboundHttpFactor> for TomlRuntimeConfigSource<'_, '_> {
382    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
383        Ok(None)
384    }
385}
386
387impl FactorRuntimeConfigSource<OutboundMqttFactor> for TomlRuntimeConfigSource<'_, '_> {
388    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
389        Ok(None)
390    }
391}
392
393impl FactorRuntimeConfigSource<SqliteFactor> for TomlRuntimeConfigSource<'_, '_> {
394    fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_sqlite::RuntimeConfig>> {
395        Ok(Some(self.sqlite.resolve(&self.toml.table)?))
396    }
397}
398
399impl RuntimeConfigSourceFinalizer for TomlRuntimeConfigSource<'_, '_> {
400    fn finalize(&mut self) -> anyhow::Result<()> {
401        Ok(self.toml.validate_all_keys_used()?)
402    }
403}
404
405const DEFAULT_KEY_VALUE_STORE_LABEL: &str = "default";
406
407/// The key-value runtime configuration resolver.
408///
409/// Takes a base path that all local key-value stores which are configured with
410/// relative paths will be relative to. It also takes a default store base path
411/// which will be used as the directory for the default store.
412pub fn key_value_config_resolver(
413    local_store_base_path: Option<PathBuf>,
414    default_store_base_path: Option<PathBuf>,
415) -> key_value::RuntimeConfigResolver {
416    let mut key_value = key_value::RuntimeConfigResolver::new();
417
418    // Register the supported store types.
419    // Unwraps are safe because the store types are known to not overlap.
420    key_value
421        .register_store_type(spin_key_value_spin::SpinKeyValueStore::new(
422            local_store_base_path.clone(),
423        ))
424        .unwrap();
425    key_value
426        .register_store_type(spin_key_value_redis::RedisKeyValueStore::new())
427        .unwrap();
428    key_value
429        .register_store_type(spin_key_value_azure::AzureKeyValueStore::new(None))
430        .unwrap();
431    key_value
432        .register_store_type(spin_key_value_aws::AwsDynamoKeyValueStore::new())
433        .unwrap();
434
435    // Add handling of "default" store.
436    let default_store_path = default_store_base_path.map(|p| p.join(DEFAULT_SPIN_STORE_FILENAME));
437    // Unwraps are safe because the store is known to be serializable as toml.
438    key_value
439        .add_default_store::<SpinKeyValueStore>(
440            DEFAULT_KEY_VALUE_STORE_LABEL,
441            SpinKeyValueRuntimeConfig::new(default_store_path),
442        )
443        .unwrap();
444
445    key_value
446}
447
448/// The default filename for the SQLite database.
449const DEFAULT_SPIN_STORE_FILENAME: &str = "sqlite_key_value.db";
450
451/// The sqlite runtime configuration resolver.
452///
453/// Takes a path to the directory where the default database should be stored.
454/// If the path is `None`, the default database will be in-memory.
455fn sqlite_config_resolver(
456    default_database_dir: Option<PathBuf>,
457) -> anyhow::Result<sqlite::RuntimeConfigResolver> {
458    let local_database_dir =
459        std::env::current_dir().context("failed to get current working directory")?;
460    Ok(sqlite::RuntimeConfigResolver::new(
461        default_database_dir,
462        local_database_dir,
463    ))
464}
465
466#[cfg(test)]
467mod tests {
468    use std::{collections::HashMap, sync::Arc};
469
470    use spin_factors::RuntimeFactors;
471    use spin_factors_test::TestEnvironment;
472
473    use super::*;
474
475    /// Define a test factor with the given field and factor type.
476    macro_rules! define_test_factor {
477        ($field:ident : $factor:ty) => {
478            #[derive(RuntimeFactors)]
479            struct TestFactors {
480                $field: $factor,
481            }
482            impl TryFrom<TomlRuntimeConfigSource<'_, '_>> for TestFactorsRuntimeConfig {
483                type Error = anyhow::Error;
484
485                fn try_from(value: TomlRuntimeConfigSource<'_, '_>) -> Result<Self, Self::Error> {
486                    Self::from_source(value)
487                }
488            }
489            fn resolve_toml(
490                toml: toml::Table,
491                path: impl AsRef<std::path::Path>,
492            ) -> anyhow::Result<ResolvedRuntimeConfig<TestFactorsRuntimeConfig>> {
493                ResolvedRuntimeConfig::<TestFactorsRuntimeConfig>::new(
494                    toml_resolver(&toml),
495                    Some(path.as_ref()),
496                )
497            }
498        };
499    }
500
501    #[test]
502    fn sqlite_is_configured_correctly() {
503        define_test_factor!(sqlite: SqliteFactor);
504
505        impl TestFactorsRuntimeConfig {
506            /// Get the connection creators for the configured sqlite databases.
507            fn connection_creators(
508                &self,
509            ) -> &HashMap<String, Arc<dyn spin_factor_sqlite::ConnectionCreator>> {
510                &self.sqlite.as_ref().unwrap().connection_creators
511            }
512
513            /// Get the labels of the configured sqlite databases.
514            fn configured_labels(&self) -> Vec<&str> {
515                let mut configured_labels = self
516                    .connection_creators()
517                    .keys()
518                    .map(|s| s.as_str())
519                    .collect::<Vec<_>>();
520                // Sort the labels to ensure consistent ordering.
521                configured_labels.sort();
522                configured_labels
523            }
524        }
525
526        // Test that the default label is added if not provided.
527        let toml = toml::toml! {
528            [sqlite_database.foo]
529            type = "spin"
530        };
531        assert_eq!(
532            resolve_toml(toml, ".")
533                .unwrap()
534                .runtime_config
535                .configured_labels(),
536            vec!["default", "foo"]
537        );
538
539        // Test that the default label is added with an empty toml config.
540        let toml = toml::Table::new();
541        let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
542        assert_eq!(runtime_config.configured_labels(), vec!["default"]);
543    }
544
545    #[test]
546    fn key_value_is_configured_correctly() {
547        define_test_factor!(key_value: KeyValueFactor);
548        impl TestFactorsRuntimeConfig {
549            /// Get whether the store manager exists for the given label.
550            fn has_store_manager(&self, label: &str) -> bool {
551                self.key_value.as_ref().unwrap().has_store_manager(label)
552            }
553        }
554
555        // Test that the default label is added if not provided.
556        let toml = toml::toml! {
557            [key_value_store.foo]
558            type = "spin"
559        };
560        let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
561        assert!(["default", "foo"]
562            .iter()
563            .all(|label| runtime_config.has_store_manager(label)));
564    }
565
566    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
567    async fn custom_spin_key_value_works_with_custom_paths() -> anyhow::Result<()> {
568        use spin_world::v2::key_value::HostStore;
569        define_test_factor!(key_value: KeyValueFactor);
570        let tmp_dir = tempfile::TempDir::with_prefix("example")?;
571        let absolute_path = tmp_dir.path().join("foo/custom.db");
572        let relative_path = tmp_dir.path().join("custom.db");
573        // Check that the dbs do not exist yet - they will exist by the end of the test
574        assert!(!absolute_path.exists());
575        assert!(!relative_path.exists());
576
577        let path_str = absolute_path.to_str().unwrap();
578        let runtime_config = toml::toml! {
579            [key_value_store.absolute]
580            type = "spin"
581            path = path_str
582
583            [key_value_store.relative]
584            type = "spin"
585            path = "custom.db"
586        };
587        let factors = TestFactors {
588            key_value: KeyValueFactor::new(),
589        };
590        let env = TestEnvironment::new(factors)
591            .extend_manifest(toml::toml! {
592                [component.test-component]
593                source = "does-not-exist.wasm"
594                key_value_stores = ["absolute", "relative"]
595            })
596            .runtime_config(
597                resolve_toml(runtime_config, tmp_dir.path().join("runtime-config.toml"))
598                    .unwrap()
599                    .runtime_config,
600            )?;
601        let mut state = env.build_instance_state().await?;
602
603        // Actually get a key since store creation is lazy
604        let store = state.key_value.open("absolute".to_owned()).await??;
605        let _ = state.key_value.get(store, "foo".to_owned()).await??;
606
607        let store = state.key_value.open("relative".to_owned()).await??;
608        let _ = state.key_value.get(store, "foo".to_owned()).await??;
609
610        // Check that the dbs have been created
611        assert!(absolute_path.exists());
612        assert!(relative_path.exists());
613        Ok(())
614    }
615
616    fn toml_resolver(toml: &toml::Table) -> TomlResolver<'_> {
617        TomlResolver::new(
618            toml,
619            None,
620            UserProvidedPath::Default,
621            UserProvidedPath::Default,
622        )
623    }
624
625    #[test]
626    fn dirs_are_resolved() {
627        define_test_factor!(sqlite: SqliteFactor);
628
629        let toml = toml::toml! {
630            state_dir = "/foo"
631            log_dir = "/bar"
632        };
633        resolve_toml(toml, "config.toml").unwrap();
634    }
635
636    #[test]
637    fn fails_to_resolve_with_unused_key() {
638        define_test_factor!(sqlite: SqliteFactor);
639
640        let toml = toml::toml! {
641            baz = "/baz"
642        };
643        // assert returns an error with value "unused runtime config key(s): local_app_dir"
644        let Err(e) = resolve_toml(toml, "config.toml") else {
645            panic!("Should not be able to resolve unknown key");
646        };
647        assert_eq!(e.to_string(), "unused runtime config key(s): baz");
648    }
649}