LCOV - code coverage report
Current view: top level - libs/compute_api/src - spec.rs (source / functions) Coverage Total Hit
Test: 5e392a02abbad1ab595f4dba672e219a49f7f539.info Lines: 93.3 % 210 196
Test Date: 2025-04-11 22:43:24 Functions: 10.5 % 266 28

            Line data    Source code
       1              : //! The ComputeSpec contains all the information needed to start up
       2              : //! the right version of PostgreSQL, and connect it to the storage nodes.
       3              : //! It can be passed as part of the `config.json`, or the control plane can
       4              : //! provide it by calling the compute_ctl's `/compute_ctl` endpoint, or
       5              : //! compute_ctl can fetch it by calling the control plane's API.
       6              : use std::collections::HashMap;
       7              : 
       8              : use indexmap::IndexMap;
       9              : use regex::Regex;
      10              : use remote_storage::RemotePath;
      11              : use serde::{Deserialize, Serialize};
      12              : use utils::id::{TenantId, TimelineId};
      13              : use utils::lsn::Lsn;
      14              : 
      15              : use crate::responses::TlsConfig;
      16              : 
      17              : /// String type alias representing Postgres identifier and
      18              : /// intended to be used for DB / role names.
      19              : pub type PgIdent = String;
      20              : 
      21              : /// String type alias representing Postgres extension version
      22              : pub type ExtVersion = String;
      23              : 
      24            6 : fn default_reconfigure_concurrency() -> usize {
      25            6 :     1
      26            6 : }
      27              : 
      28              : /// Cluster spec or configuration represented as an optional number of
      29              : /// delta operations + final cluster state description.
      30           45 : #[derive(Clone, Debug, Default, Deserialize, Serialize)]
      31              : pub struct ComputeSpec {
      32              :     pub format_version: f32,
      33              : 
      34              :     // The control plane also includes a 'timestamp' field in the JSON document,
      35              :     // but we don't use it for anything. Serde will ignore missing fields when
      36              :     // deserializing it.
      37              :     pub operation_uuid: Option<String>,
      38              : 
      39              :     /// Compute features to enable. These feature flags are provided, when we
      40              :     /// know all the details about client's compute, so they cannot be used
      41              :     /// to change `Empty` compute behavior.
      42              :     #[serde(default)]
      43              :     pub features: Vec<ComputeFeature>,
      44              : 
      45              :     /// If compute_ctl was passed `--resize-swap-on-bind`, a value of `Some(_)` instructs
      46              :     /// compute_ctl to `/neonvm/bin/resize-swap` with the given size, when the spec is first
      47              :     /// received.
      48              :     ///
      49              :     /// Both this field and `--resize-swap-on-bind` are required, so that the control plane's
      50              :     /// spec generation doesn't need to be aware of the actual compute it's running on, while
      51              :     /// guaranteeing gradual rollout of swap. Otherwise, without `--resize-swap-on-bind`, we could
      52              :     /// end up trying to resize swap in VMs without it -- or end up *not* resizing swap, thus
      53              :     /// giving every VM much more swap than it should have (32GiB).
      54              :     ///
      55              :     /// Eventually we may remove `--resize-swap-on-bind` and exclusively use `swap_size_bytes` for
      56              :     /// enabling the swap resizing behavior once rollout is complete.
      57              :     ///
      58              :     /// See neondatabase/cloud#12047 for more.
      59              :     #[serde(default)]
      60              :     pub swap_size_bytes: Option<u64>,
      61              : 
      62              :     /// If compute_ctl was passed `--set-disk-quota-for-fs`, a value of `Some(_)` instructs
      63              :     /// compute_ctl to run `/neonvm/bin/set-disk-quota` with the given size and fs, when the
      64              :     /// spec is first received.
      65              :     ///
      66              :     /// Both this field and `--set-disk-quota-for-fs` are required, so that the control plane's
      67              :     /// spec generation doesn't need to be aware of the actual compute it's running on, while
      68              :     /// guaranteeing gradual rollout of disk quota.
      69              :     #[serde(default)]
      70              :     pub disk_quota_bytes: Option<u64>,
      71              : 
      72              :     /// Disables the vm-monitor behavior that resizes LFC on upscale/downscale, instead relying on
      73              :     /// the initial size of LFC.
      74              :     ///
      75              :     /// This is intended for use when the LFC size is being overridden from the default but
      76              :     /// autoscaling is still enabled, and we don't want the vm-monitor to interfere with the custom
      77              :     /// LFC sizing.
      78              :     #[serde(default)]
      79              :     pub disable_lfc_resizing: Option<bool>,
      80              : 
      81              :     /// Expected cluster state at the end of transition process.
      82              :     pub cluster: Cluster,
      83              :     pub delta_operations: Option<Vec<DeltaOp>>,
      84              : 
      85              :     /// An optional hint that can be passed to speed up startup time if we know
      86              :     /// that no pg catalog mutations (like role creation, database creation,
      87              :     /// extension creation) need to be done on the actual database to start.
      88              :     #[serde(default)] // Default false
      89              :     pub skip_pg_catalog_updates: bool,
      90              : 
      91              :     // Information needed to connect to the storage layer.
      92              :     //
      93              :     // `tenant_id`, `timeline_id` and `pageserver_connstring` are always needed.
      94              :     //
      95              :     // Depending on `mode`, this can be a primary read-write node, a read-only
      96              :     // replica, or a read-only node pinned at an older LSN.
      97              :     // `safekeeper_connstrings` must be set for a primary.
      98              :     //
      99              :     // For backwards compatibility, the control plane may leave out all of
     100              :     // these, and instead set the "neon.tenant_id", "neon.timeline_id",
     101              :     // etc. GUCs in cluster.settings. TODO: Once the control plane has been
     102              :     // updated to fill these fields, we can make these non optional.
     103              :     pub tenant_id: Option<TenantId>,
     104              :     pub timeline_id: Option<TimelineId>,
     105              :     pub pageserver_connstring: Option<String>,
     106              : 
     107              :     // More neon ids that we expose to the compute_ctl
     108              :     // and to postgres as neon extension GUCs.
     109              :     pub project_id: Option<String>,
     110              :     pub branch_id: Option<String>,
     111              :     pub endpoint_id: Option<String>,
     112              : 
     113              :     /// Safekeeper membership config generation. It is put in
     114              :     /// neon.safekeepers GUC and serves two purposes:
     115              :     /// 1) Non zero value forces walproposer to use membership configurations.
     116              :     /// 2) If walproposer wants to update list of safekeepers to connect to
     117              :     ///    taking them from some safekeeper mconf, it should check what value
     118              :     ///    is newer by comparing the generation.
     119              :     ///
     120              :     /// Note: it could be SafekeeperGeneration, but this needs linking
     121              :     /// compute_ctl with postgres_ffi.
     122              :     #[serde(default)]
     123              :     pub safekeepers_generation: Option<u32>,
     124              :     #[serde(default)]
     125              :     pub safekeeper_connstrings: Vec<String>,
     126              : 
     127              :     #[serde(default)]
     128              :     pub mode: ComputeMode,
     129              : 
     130              :     /// If set, 'storage_auth_token' is used as the password to authenticate to
     131              :     /// the pageserver and safekeepers.
     132              :     pub storage_auth_token: Option<String>,
     133              : 
     134              :     // information about available remote extensions
     135              :     pub remote_extensions: Option<RemoteExtSpec>,
     136              : 
     137              :     pub pgbouncer_settings: Option<IndexMap<String, String>>,
     138              : 
     139              :     // Stripe size for pageserver sharding, in pages
     140              :     #[serde(default)]
     141              :     pub shard_stripe_size: Option<usize>,
     142              : 
     143              :     /// Local Proxy configuration used for JWT authentication
     144              :     #[serde(default)]
     145              :     pub local_proxy_config: Option<LocalProxySpec>,
     146              : 
     147              :     /// Number of concurrent connections during the parallel RunInEachDatabase
     148              :     /// phase of the apply config process.
     149              :     ///
     150              :     /// We need a higher concurrency during reconfiguration in case of many DBs,
     151              :     /// but instance is already running and used by client. We can easily get out of
     152              :     /// `max_connections` limit, and the current code won't handle that.
     153              :     ///
     154              :     /// Default is 1, but also allow control plane to override this value for specific
     155              :     /// projects. It's also recommended to bump `superuser_reserved_connections` +=
     156              :     /// `reconfigure_concurrency` for such projects to ensure that we always have
     157              :     /// enough spare connections for reconfiguration process to succeed.
     158              :     #[serde(default = "default_reconfigure_concurrency")]
     159              :     pub reconfigure_concurrency: usize,
     160              : 
     161              :     /// If set to true, the compute_ctl will drop all subscriptions before starting the
     162              :     /// compute. This is needed when we start an endpoint on a branch, so that child
     163              :     /// would not compete with parent branch subscriptions
     164              :     /// over the same replication content from publisher.
     165              :     #[serde(default)] // Default false
     166              :     pub drop_subscriptions_before_start: bool,
     167              : 
     168              :     /// Log level for compute audit logging
     169              :     #[serde(default)]
     170              :     pub audit_log_level: ComputeAudit,
     171              : 
     172              :     /// Hostname and the port of the otel collector. Leave empty to disable Postgres logs forwarding.
     173              :     /// Example: config-shy-breeze-123-collector-monitoring.neon-telemetry.svc.cluster.local:10514
     174              :     pub logs_export_host: Option<String>,
     175              : }
     176              : 
     177              : /// Feature flag to signal `compute_ctl` to enable certain experimental functionality.
     178            3 : #[derive(Serialize, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
     179              : #[serde(rename_all = "snake_case")]
     180              : pub enum ComputeFeature {
     181              :     // XXX: Add more feature flags here.
     182              :     /// Enable the experimental activity monitor logic, which uses `pg_stat_database` to
     183              :     /// track short-lived connections as user activity.
     184              :     ActivityMonitorExperimental,
     185              : 
     186              :     /// This is a special feature flag that is used to represent unknown feature flags.
     187              :     /// Basically all unknown to enum flags are represented as this one. See unit test
     188              :     /// `parse_unknown_features()` for more details.
     189              :     #[serde(other)]
     190              :     UnknownFeature,
     191              : }
     192              : 
     193           44 : #[derive(Clone, Debug, Default, Deserialize, Serialize)]
     194              : pub struct RemoteExtSpec {
     195              :     pub public_extensions: Option<Vec<String>>,
     196              :     pub custom_extensions: Option<Vec<String>>,
     197              :     pub library_index: HashMap<String, String>,
     198              :     pub extension_data: HashMap<String, ExtensionData>,
     199              : }
     200              : 
     201           18 : #[derive(Clone, Debug, Serialize, Deserialize)]
     202              : pub struct ExtensionData {
     203              :     pub control_data: HashMap<String, String>,
     204              :     pub archive_path: String,
     205              : }
     206              : 
     207              : impl RemoteExtSpec {
     208            6 :     pub fn get_ext(
     209            6 :         &self,
     210            6 :         ext_name: &str,
     211            6 :         is_library: bool,
     212            6 :         build_tag: &str,
     213            6 :         pg_major_version: &str,
     214            6 :     ) -> anyhow::Result<(String, RemotePath)> {
     215            6 :         let mut real_ext_name = ext_name;
     216            6 :         if is_library {
     217              :             // sometimes library names might have a suffix like
     218              :             // library.so or library.so.3. We strip this off
     219              :             // because library_index is based on the name without the file extension
     220            1 :             let strip_lib_suffix = Regex::new(r"\.so.*").unwrap();
     221            1 :             let lib_raw_name = strip_lib_suffix.replace(real_ext_name, "").to_string();
     222            1 : 
     223            1 :             real_ext_name = self
     224            1 :                 .library_index
     225            1 :                 .get(&lib_raw_name)
     226            1 :                 .ok_or(anyhow::anyhow!("library {} is not found", lib_raw_name))?;
     227            5 :         }
     228              : 
     229              :         // Check if extension is present in public or custom.
     230              :         // If not, then it is not allowed to be used by this compute.
     231            6 :         if !self
     232            6 :             .public_extensions
     233            6 :             .as_ref()
     234            6 :             .is_some_and(|exts| exts.iter().any(|e| e == real_ext_name))
     235            4 :             && !self
     236            4 :                 .custom_extensions
     237            4 :                 .as_ref()
     238            4 :                 .is_some_and(|exts| exts.iter().any(|e| e == real_ext_name))
     239              :         {
     240            3 :             return Err(anyhow::anyhow!("extension {} is not found", real_ext_name));
     241            3 :         }
     242            3 : 
     243            3 :         match self.extension_data.get(real_ext_name) {
     244            3 :             Some(_ext_data) => {
     245            3 :                 // Construct the path to the extension archive
     246            3 :                 // BUILD_TAG/PG_MAJOR_VERSION/extensions/EXTENSION_NAME.tar.zst
     247            3 :                 //
     248            3 :                 // Keep it in sync with path generation in
     249            3 :                 // https://github.com/neondatabase/build-custom-extensions/tree/main
     250            3 :                 let archive_path_str =
     251            3 :                     format!("{build_tag}/{pg_major_version}/extensions/{real_ext_name}.tar.zst");
     252            3 :                 Ok((
     253            3 :                     real_ext_name.to_string(),
     254            3 :                     RemotePath::from_string(&archive_path_str)?,
     255              :                 ))
     256              :             }
     257            0 :             None => Err(anyhow::anyhow!(
     258            0 :                 "real_ext_name {} is not found",
     259            0 :                 real_ext_name
     260            0 :             )),
     261              :         }
     262            6 :     }
     263              : }
     264              : 
     265            0 : #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
     266              : pub enum ComputeMode {
     267              :     /// A read-write node
     268              :     #[default]
     269              :     Primary,
     270              :     /// A read-only node, pinned at a particular LSN
     271              :     Static(Lsn),
     272              :     /// A read-only node that follows the tip of the branch in hot standby mode
     273              :     ///
     274              :     /// Future versions may want to distinguish between replicas with hot standby
     275              :     /// feedback and other kinds of replication configurations.
     276              :     Replica,
     277              : }
     278              : 
     279              : impl ComputeMode {
     280              :     /// Convert the compute mode to a string that can be used to identify the type of compute,
     281              :     /// which means that if it's a static compute, the LSN will not be included.
     282            0 :     pub fn to_type_str(&self) -> &'static str {
     283            0 :         match self {
     284            0 :             ComputeMode::Primary => "primary",
     285            0 :             ComputeMode::Static(_) => "static",
     286            0 :             ComputeMode::Replica => "replica",
     287              :         }
     288            0 :     }
     289              : }
     290              : 
     291              : /// Log level for audit logging
     292            0 : #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
     293              : pub enum ComputeAudit {
     294              :     #[default]
     295              :     Disabled,
     296              :     // Deprecated, use Base instead
     297              :     Log,
     298              :     // (pgaudit.log = 'ddl', pgaudit.log_parameter='off')
     299              :     // logged to the standard postgresql log stream
     300              :     Base,
     301              :     // Deprecated, use Full or Extended instead
     302              :     Hipaa,
     303              :     // (pgaudit.log = 'all, -misc', pgaudit.log_parameter='off')
     304              :     // logged to separate files collected by rsyslog
     305              :     // into dedicated log storage with strict access
     306              :     Extended,
     307              :     // (pgaudit.log='all', pgaudit.log_parameter='on'),
     308              :     // logged to separate files collected by rsyslog
     309              :     // into dedicated log storage with strict access.
     310              :     Full,
     311              : }
     312              : 
     313           36 : #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
     314              : pub struct Cluster {
     315              :     pub cluster_id: Option<String>,
     316              :     pub name: Option<String>,
     317              :     pub state: Option<String>,
     318              :     pub roles: Vec<Role>,
     319              :     pub databases: Vec<Database>,
     320              : 
     321              :     /// Desired contents of 'postgresql.conf' file. (The 'compute_ctl'
     322              :     /// tool may add additional settings to the final file.)
     323              :     pub postgresql_conf: Option<String>,
     324              : 
     325              :     /// Additional settings that will be appended to the 'postgresql.conf' file.
     326              :     pub settings: GenericOptions,
     327              : }
     328              : 
     329              : /// Single cluster state changing operation that could not be represented as
     330              : /// a static `Cluster` structure. For example:
     331              : /// - DROP DATABASE
     332              : /// - DROP ROLE
     333              : /// - ALTER ROLE name RENAME TO new_name
     334              : /// - ALTER DATABASE name RENAME TO new_name
     335           60 : #[derive(Clone, Debug, Deserialize, Serialize)]
     336              : pub struct DeltaOp {
     337              :     pub action: String,
     338              :     pub name: PgIdent,
     339              :     pub new_name: Option<PgIdent>,
     340              : }
     341              : 
     342              : /// Rust representation of Postgres role info with only those fields
     343              : /// that matter for us.
     344           90 : #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
     345              : pub struct Role {
     346              :     pub name: PgIdent,
     347              :     pub encrypted_password: Option<String>,
     348              :     pub options: GenericOptions,
     349              : }
     350              : 
     351              : /// Rust representation of Postgres database info with only those fields
     352              : /// that matter for us.
     353           42 : #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
     354              : pub struct Database {
     355              :     pub name: PgIdent,
     356              :     pub owner: PgIdent,
     357              :     pub options: GenericOptions,
     358              :     // These are derived flags, not present in the spec file.
     359              :     // They are never set by the control plane.
     360              :     #[serde(skip_deserializing, default)]
     361              :     pub restrict_conn: bool,
     362              :     #[serde(skip_deserializing, default)]
     363              :     pub invalid: bool,
     364              : }
     365              : 
     366              : /// Common type representing both SQL statement params with or without value,
     367              : /// like `LOGIN` or `OWNER username` in the `CREATE/ALTER ROLE`, and config
     368              : /// options like `wal_level = logical`.
     369          468 : #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
     370              : pub struct GenericOption {
     371              :     pub name: String,
     372              :     pub value: Option<String>,
     373              :     pub vartype: String,
     374              : }
     375              : 
     376              : /// Optional collection of `GenericOption`'s. Type alias allows us to
     377              : /// declare a `trait` on it.
     378              : pub type GenericOptions = Option<Vec<GenericOption>>;
     379              : 
     380              : /// Configured the local_proxy application with the relevant JWKS and roles it should
     381              : /// use for authorizing connect requests using JWT.
     382            0 : #[derive(Clone, Debug, Deserialize, Serialize)]
     383              : pub struct LocalProxySpec {
     384              :     #[serde(default)]
     385              :     #[serde(skip_serializing_if = "Option::is_none")]
     386              :     pub jwks: Option<Vec<JwksSettings>>,
     387              :     #[serde(default)]
     388              :     #[serde(skip_serializing_if = "Option::is_none")]
     389              :     pub tls: Option<TlsConfig>,
     390              : }
     391              : 
     392            0 : #[derive(Clone, Debug, Deserialize, Serialize)]
     393              : pub struct JwksSettings {
     394              :     pub id: String,
     395              :     pub role_names: Vec<String>,
     396              :     pub jwks_url: String,
     397              :     pub provider_name: String,
     398              :     pub jwt_audience: Option<String>,
     399              : }
     400              : 
     401              : #[cfg(test)]
     402              : mod tests {
     403              :     use std::fs::File;
     404              : 
     405              :     use super::*;
     406              : 
     407              :     #[test]
     408            1 :     fn allow_installing_remote_extensions() {
     409            1 :         let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
     410            1 :             "public_extensions": null,
     411            1 :             "custom_extensions": null,
     412            1 :             "library_index": {},
     413            1 :             "extension_data": {},
     414            1 :         }))
     415            1 :         .unwrap();
     416            1 : 
     417            1 :         rspec
     418            1 :             .get_ext("ext", false, "latest", "v17")
     419            1 :             .expect_err("Extension should not be found");
     420            1 : 
     421            1 :         let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
     422            1 :             "public_extensions": [],
     423            1 :             "custom_extensions": null,
     424            1 :             "library_index": {},
     425            1 :             "extension_data": {},
     426            1 :         }))
     427            1 :         .unwrap();
     428            1 : 
     429            1 :         rspec
     430            1 :             .get_ext("ext", false, "latest", "v17")
     431            1 :             .expect_err("Extension should not be found");
     432            1 : 
     433            1 :         let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
     434            1 :             "public_extensions": [],
     435            1 :             "custom_extensions": [],
     436            1 :             "library_index": {
     437            1 :                 "ext": "ext"
     438            1 :             },
     439            1 :             "extension_data": {
     440            1 :                 "ext": {
     441            1 :                     "control_data": {
     442            1 :                         "ext.control": ""
     443            1 :                     },
     444            1 :                     "archive_path": ""
     445            1 :                 }
     446            1 :             },
     447            1 :         }))
     448            1 :         .unwrap();
     449            1 : 
     450            1 :         rspec
     451            1 :             .get_ext("ext", false, "latest", "v17")
     452            1 :             .expect_err("Extension should not be found");
     453            1 : 
     454            1 :         let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
     455            1 :             "public_extensions": [],
     456            1 :             "custom_extensions": ["ext"],
     457            1 :             "library_index": {
     458            1 :                 "ext": "ext"
     459            1 :             },
     460            1 :             "extension_data": {
     461            1 :                 "ext": {
     462            1 :                     "control_data": {
     463            1 :                         "ext.control": ""
     464            1 :                     },
     465            1 :                     "archive_path": ""
     466            1 :                 }
     467            1 :             },
     468            1 :         }))
     469            1 :         .unwrap();
     470            1 : 
     471            1 :         rspec
     472            1 :             .get_ext("ext", false, "latest", "v17")
     473            1 :             .expect("Extension should be found");
     474            1 : 
     475            1 :         let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
     476            1 :             "public_extensions": ["ext"],
     477            1 :             "custom_extensions": [],
     478            1 :             "library_index": {
     479            1 :                 "extlib": "ext",
     480            1 :             },
     481            1 :             "extension_data": {
     482            1 :                 "ext": {
     483            1 :                     "control_data": {
     484            1 :                         "ext.control": ""
     485            1 :                     },
     486            1 :                     "archive_path": ""
     487            1 :                 }
     488            1 :             },
     489            1 :         }))
     490            1 :         .unwrap();
     491            1 : 
     492            1 :         rspec
     493            1 :             .get_ext("ext", false, "latest", "v17")
     494            1 :             .expect("Extension should be found");
     495            1 : 
     496            1 :         // test library index for the case when library name
     497            1 :         // doesn't match the extension name
     498            1 :         rspec
     499            1 :             .get_ext("extlib", true, "latest", "v17")
     500            1 :             .expect("Library should be found");
     501            1 :     }
     502              : 
     503              :     #[test]
     504            1 :     fn parse_spec_file() {
     505            1 :         let file = File::open("tests/cluster_spec.json").unwrap();
     506            1 :         let spec: ComputeSpec = serde_json::from_reader(file).unwrap();
     507            1 : 
     508            1 :         // Features list defaults to empty vector.
     509            1 :         assert!(spec.features.is_empty());
     510              : 
     511              :         // Reconfigure concurrency defaults to 1.
     512            1 :         assert_eq!(spec.reconfigure_concurrency, 1);
     513            1 :     }
     514              : 
     515              :     #[test]
     516            1 :     fn parse_unknown_fields() {
     517            1 :         // Forward compatibility test
     518            1 :         let file = File::open("tests/cluster_spec.json").unwrap();
     519            1 :         let mut json: serde_json::Value = serde_json::from_reader(file).unwrap();
     520            1 :         let ob = json.as_object_mut().unwrap();
     521            1 :         ob.insert("unknown_field_123123123".into(), "hello".into());
     522            1 :         let _spec: ComputeSpec = serde_json::from_value(json).unwrap();
     523            1 :     }
     524              : 
     525              :     #[test]
     526            1 :     fn parse_unknown_features() {
     527            1 :         // Test that unknown feature flags do not cause any errors.
     528            1 :         let file = File::open("tests/cluster_spec.json").unwrap();
     529            1 :         let mut json: serde_json::Value = serde_json::from_reader(file).unwrap();
     530            1 :         let ob = json.as_object_mut().unwrap();
     531            1 : 
     532            1 :         // Add unknown feature flags.
     533            1 :         let features = vec!["foo_bar_feature", "baz_feature"];
     534            1 :         ob.insert("features".into(), features.into());
     535            1 : 
     536            1 :         let spec: ComputeSpec = serde_json::from_value(json).unwrap();
     537            1 : 
     538            1 :         assert!(spec.features.len() == 2);
     539            1 :         assert!(spec.features.contains(&ComputeFeature::UnknownFeature));
     540            1 :         assert_eq!(spec.features, vec![ComputeFeature::UnknownFeature; 2]);
     541            1 :     }
     542              : 
     543              :     #[test]
     544            1 :     fn parse_known_features() {
     545            1 :         // Test that we can properly parse known feature flags.
     546            1 :         let file = File::open("tests/cluster_spec.json").unwrap();
     547            1 :         let mut json: serde_json::Value = serde_json::from_reader(file).unwrap();
     548            1 :         let ob = json.as_object_mut().unwrap();
     549            1 : 
     550            1 :         // Add known feature flags.
     551            1 :         let features = vec!["activity_monitor_experimental"];
     552            1 :         ob.insert("features".into(), features.into());
     553            1 : 
     554            1 :         let spec: ComputeSpec = serde_json::from_value(json).unwrap();
     555            1 : 
     556            1 :         assert_eq!(
     557            1 :             spec.features,
     558            1 :             vec![ComputeFeature::ActivityMonitorExperimental]
     559            1 :         );
     560            1 :     }
     561              : }
        

Generated by: LCOV version 2.1-beta