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 : /// Address of endpoint storage service
177 : pub endpoint_storage_addr: Option<String>,
178 : /// JWT for authorizing requests to endpoint storage service
179 : pub endpoint_storage_token: Option<String>,
180 :
181 : /// Download LFC state from endpoint_storage and pass it to Postgres on startup
182 : #[serde(default)]
183 : pub autoprewarm: bool,
184 : }
185 :
186 : /// Feature flag to signal `compute_ctl` to enable certain experimental functionality.
187 3 : #[derive(Serialize, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
188 : #[serde(rename_all = "snake_case")]
189 : pub enum ComputeFeature {
190 : // XXX: Add more feature flags here.
191 : /// Enable the experimental activity monitor logic, which uses `pg_stat_database` to
192 : /// track short-lived connections as user activity.
193 : ActivityMonitorExperimental,
194 :
195 : /// Enable TLS functionality.
196 : TlsExperimental,
197 :
198 : /// This is a special feature flag that is used to represent unknown feature flags.
199 : /// Basically all unknown to enum flags are represented as this one. See unit test
200 : /// `parse_unknown_features()` for more details.
201 : #[serde(other)]
202 : UnknownFeature,
203 : }
204 :
205 48 : #[derive(Clone, Debug, Default, Deserialize, Serialize)]
206 : pub struct RemoteExtSpec {
207 : pub public_extensions: Option<Vec<String>>,
208 : pub custom_extensions: Option<Vec<String>>,
209 : pub library_index: HashMap<String, String>,
210 : pub extension_data: HashMap<String, ExtensionData>,
211 : }
212 :
213 20 : #[derive(Clone, Debug, Serialize, Deserialize)]
214 : pub struct ExtensionData {
215 : pub control_data: HashMap<String, String>,
216 : pub archive_path: String,
217 : }
218 :
219 : impl RemoteExtSpec {
220 7 : pub fn get_ext(
221 7 : &self,
222 7 : ext_name: &str,
223 7 : is_library: bool,
224 7 : build_tag: &str,
225 7 : pg_major_version: &str,
226 7 : ) -> anyhow::Result<(String, RemotePath)> {
227 7 : let mut real_ext_name = ext_name;
228 7 : if is_library {
229 : // sometimes library names might have a suffix like
230 : // library.so or library.so.3. We strip this off
231 : // because library_index is based on the name without the file extension
232 1 : let strip_lib_suffix = Regex::new(r"\.so.*").unwrap();
233 1 : let lib_raw_name = strip_lib_suffix.replace(real_ext_name, "").to_string();
234 1 :
235 1 : real_ext_name = self
236 1 : .library_index
237 1 : .get(&lib_raw_name)
238 1 : .ok_or(anyhow::anyhow!("library {} is not found", lib_raw_name))?;
239 6 : }
240 :
241 : // Check if extension is present in public or custom.
242 : // If not, then it is not allowed to be used by this compute.
243 7 : if !self
244 7 : .public_extensions
245 7 : .as_ref()
246 7 : .is_some_and(|exts| exts.iter().any(|e| e == real_ext_name))
247 4 : && !self
248 4 : .custom_extensions
249 4 : .as_ref()
250 4 : .is_some_and(|exts| exts.iter().any(|e| e == real_ext_name))
251 : {
252 3 : return Err(anyhow::anyhow!("extension {} is not found", real_ext_name));
253 4 : }
254 4 :
255 4 : match self.extension_data.get(real_ext_name) {
256 4 : Some(_ext_data) => Ok((
257 4 : real_ext_name.to_string(),
258 4 : Self::build_remote_path(build_tag, pg_major_version, real_ext_name)?,
259 : )),
260 0 : None => Err(anyhow::anyhow!(
261 0 : "real_ext_name {} is not found",
262 0 : real_ext_name
263 0 : )),
264 : }
265 7 : }
266 :
267 : /// Get the architecture-specific portion of the remote extension path. We
268 : /// use the Go naming convention due to Kubernetes.
269 5 : fn get_arch() -> &'static str {
270 5 : match std::env::consts::ARCH {
271 5 : "x86_64" => "amd64",
272 0 : "aarch64" => "arm64",
273 0 : arch => arch,
274 : }
275 5 : }
276 :
277 : /// Build a [`RemotePath`] for an extension.
278 5 : fn build_remote_path(
279 5 : build_tag: &str,
280 5 : pg_major_version: &str,
281 5 : ext_name: &str,
282 5 : ) -> anyhow::Result<RemotePath> {
283 5 : let arch = Self::get_arch();
284 5 :
285 5 : // Construct the path to the extension archive
286 5 : // BUILD_TAG/PG_MAJOR_VERSION/extensions/EXTENSION_NAME.tar.zst
287 5 : //
288 5 : // Keep it in sync with path generation in
289 5 : // https://github.com/neondatabase/build-custom-extensions/tree/main
290 5 : RemotePath::from_string(&format!(
291 5 : "{build_tag}/{arch}/{pg_major_version}/extensions/{ext_name}.tar.zst"
292 5 : ))
293 5 : }
294 : }
295 :
296 0 : #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
297 : pub enum ComputeMode {
298 : /// A read-write node
299 : #[default]
300 : Primary,
301 : /// A read-only node, pinned at a particular LSN
302 : Static(Lsn),
303 : /// A read-only node that follows the tip of the branch in hot standby mode
304 : ///
305 : /// Future versions may want to distinguish between replicas with hot standby
306 : /// feedback and other kinds of replication configurations.
307 : Replica,
308 : }
309 :
310 : impl ComputeMode {
311 : /// Convert the compute mode to a string that can be used to identify the type of compute,
312 : /// which means that if it's a static compute, the LSN will not be included.
313 0 : pub fn to_type_str(&self) -> &'static str {
314 0 : match self {
315 0 : ComputeMode::Primary => "primary",
316 0 : ComputeMode::Static(_) => "static",
317 0 : ComputeMode::Replica => "replica",
318 : }
319 0 : }
320 : }
321 :
322 : /// Log level for audit logging
323 0 : #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
324 : pub enum ComputeAudit {
325 : #[default]
326 : Disabled,
327 : // Deprecated, use Base instead
328 : Log,
329 : // (pgaudit.log = 'ddl', pgaudit.log_parameter='off')
330 : // logged to the standard postgresql log stream
331 : Base,
332 : // Deprecated, use Full or Extended instead
333 : Hipaa,
334 : // (pgaudit.log = 'all, -misc', pgaudit.log_parameter='off')
335 : // logged to separate files collected by rsyslog
336 : // into dedicated log storage with strict access
337 : Extended,
338 : // (pgaudit.log='all', pgaudit.log_parameter='on'),
339 : // logged to separate files collected by rsyslog
340 : // into dedicated log storage with strict access.
341 : Full,
342 : }
343 :
344 36 : #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
345 : pub struct Cluster {
346 : pub cluster_id: Option<String>,
347 : pub name: Option<String>,
348 : pub state: Option<String>,
349 : pub roles: Vec<Role>,
350 : pub databases: Vec<Database>,
351 :
352 : /// Desired contents of 'postgresql.conf' file. (The 'compute_ctl'
353 : /// tool may add additional settings to the final file.)
354 : pub postgresql_conf: Option<String>,
355 :
356 : /// Additional settings that will be appended to the 'postgresql.conf' file.
357 : pub settings: GenericOptions,
358 : }
359 :
360 : /// Single cluster state changing operation that could not be represented as
361 : /// a static `Cluster` structure. For example:
362 : /// - DROP DATABASE
363 : /// - DROP ROLE
364 : /// - ALTER ROLE name RENAME TO new_name
365 : /// - ALTER DATABASE name RENAME TO new_name
366 60 : #[derive(Clone, Debug, Deserialize, Serialize)]
367 : pub struct DeltaOp {
368 : pub action: String,
369 : pub name: PgIdent,
370 : pub new_name: Option<PgIdent>,
371 : }
372 :
373 : /// Rust representation of Postgres role info with only those fields
374 : /// that matter for us.
375 90 : #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
376 : pub struct Role {
377 : pub name: PgIdent,
378 : pub encrypted_password: Option<String>,
379 : pub options: GenericOptions,
380 : }
381 :
382 : /// Rust representation of Postgres database info with only those fields
383 : /// that matter for us.
384 42 : #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
385 : pub struct Database {
386 : pub name: PgIdent,
387 : pub owner: PgIdent,
388 : pub options: GenericOptions,
389 : // These are derived flags, not present in the spec file.
390 : // They are never set by the control plane.
391 : #[serde(skip_deserializing, default)]
392 : pub restrict_conn: bool,
393 : #[serde(skip_deserializing, default)]
394 : pub invalid: bool,
395 : }
396 :
397 : /// Common type representing both SQL statement params with or without value,
398 : /// like `LOGIN` or `OWNER username` in the `CREATE/ALTER ROLE`, and config
399 : /// options like `wal_level = logical`.
400 486 : #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
401 : pub struct GenericOption {
402 : pub name: String,
403 : pub value: Option<String>,
404 : pub vartype: String,
405 : }
406 :
407 : /// Optional collection of `GenericOption`'s. Type alias allows us to
408 : /// declare a `trait` on it.
409 : pub type GenericOptions = Option<Vec<GenericOption>>;
410 :
411 : /// Configured the local_proxy application with the relevant JWKS and roles it should
412 : /// use for authorizing connect requests using JWT.
413 0 : #[derive(Clone, Debug, Deserialize, Serialize)]
414 : pub struct LocalProxySpec {
415 : #[serde(default)]
416 : #[serde(skip_serializing_if = "Option::is_none")]
417 : pub jwks: Option<Vec<JwksSettings>>,
418 : #[serde(default)]
419 : #[serde(skip_serializing_if = "Option::is_none")]
420 : pub tls: Option<TlsConfig>,
421 : }
422 :
423 0 : #[derive(Clone, Debug, Deserialize, Serialize)]
424 : pub struct JwksSettings {
425 : pub id: String,
426 : pub role_names: Vec<String>,
427 : pub jwks_url: String,
428 : pub provider_name: String,
429 : pub jwt_audience: Option<String>,
430 : }
431 :
432 : #[cfg(test)]
433 : mod tests {
434 : use std::fs::File;
435 :
436 : use super::*;
437 :
438 : #[test]
439 1 : fn allow_installing_remote_extensions() {
440 1 : let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
441 1 : "public_extensions": null,
442 1 : "custom_extensions": null,
443 1 : "library_index": {},
444 1 : "extension_data": {},
445 1 : }))
446 1 : .unwrap();
447 1 :
448 1 : rspec
449 1 : .get_ext("ext", false, "latest", "v17")
450 1 : .expect_err("Extension should not be found");
451 1 :
452 1 : let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
453 1 : "public_extensions": [],
454 1 : "custom_extensions": null,
455 1 : "library_index": {},
456 1 : "extension_data": {},
457 1 : }))
458 1 : .unwrap();
459 1 :
460 1 : rspec
461 1 : .get_ext("ext", false, "latest", "v17")
462 1 : .expect_err("Extension should not be found");
463 1 :
464 1 : let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
465 1 : "public_extensions": [],
466 1 : "custom_extensions": [],
467 1 : "library_index": {
468 1 : "ext": "ext"
469 1 : },
470 1 : "extension_data": {
471 1 : "ext": {
472 1 : "control_data": {
473 1 : "ext.control": ""
474 1 : },
475 1 : "archive_path": ""
476 1 : }
477 1 : },
478 1 : }))
479 1 : .unwrap();
480 1 :
481 1 : rspec
482 1 : .get_ext("ext", false, "latest", "v17")
483 1 : .expect_err("Extension should not be found");
484 1 :
485 1 : let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
486 1 : "public_extensions": [],
487 1 : "custom_extensions": ["ext"],
488 1 : "library_index": {
489 1 : "ext": "ext"
490 1 : },
491 1 : "extension_data": {
492 1 : "ext": {
493 1 : "control_data": {
494 1 : "ext.control": ""
495 1 : },
496 1 : "archive_path": ""
497 1 : }
498 1 : },
499 1 : }))
500 1 : .unwrap();
501 1 :
502 1 : rspec
503 1 : .get_ext("ext", false, "latest", "v17")
504 1 : .expect("Extension should be found");
505 1 :
506 1 : let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
507 1 : "public_extensions": ["ext"],
508 1 : "custom_extensions": [],
509 1 : "library_index": {
510 1 : "extlib": "ext",
511 1 : },
512 1 : "extension_data": {
513 1 : "ext": {
514 1 : "control_data": {
515 1 : "ext.control": ""
516 1 : },
517 1 : "archive_path": ""
518 1 : }
519 1 : },
520 1 : }))
521 1 : .unwrap();
522 1 :
523 1 : rspec
524 1 : .get_ext("ext", false, "latest", "v17")
525 1 : .expect("Extension should be found");
526 1 :
527 1 : // test library index for the case when library name
528 1 : // doesn't match the extension name
529 1 : rspec
530 1 : .get_ext("extlib", true, "latest", "v17")
531 1 : .expect("Library should be found");
532 1 : }
533 :
534 : #[test]
535 1 : fn remote_extension_path() {
536 1 : let rspec: RemoteExtSpec = serde_json::from_value(serde_json::json!({
537 1 : "public_extensions": ["ext"],
538 1 : "custom_extensions": [],
539 1 : "library_index": {
540 1 : "extlib": "ext",
541 1 : },
542 1 : "extension_data": {
543 1 : "ext": {
544 1 : "control_data": {
545 1 : "ext.control": ""
546 1 : },
547 1 : "archive_path": ""
548 1 : }
549 1 : },
550 1 : }))
551 1 : .unwrap();
552 1 :
553 1 : let (_ext_name, ext_path) = rspec
554 1 : .get_ext("ext", false, "latest", "v17")
555 1 : .expect("Extension should be found");
556 1 : // Starting with a forward slash would have consequences for the
557 1 : // Url::join() that occurs when downloading a remote extension.
558 1 : assert!(!ext_path.to_string().starts_with("/"));
559 1 : assert_eq!(
560 1 : ext_path,
561 1 : RemoteExtSpec::build_remote_path("latest", "v17", "ext").unwrap()
562 1 : );
563 1 : }
564 :
565 : #[test]
566 1 : fn parse_spec_file() {
567 1 : let file = File::open("tests/cluster_spec.json").unwrap();
568 1 : let spec: ComputeSpec = serde_json::from_reader(file).unwrap();
569 1 :
570 1 : // Features list defaults to empty vector.
571 1 : assert!(spec.features.is_empty());
572 :
573 : // Reconfigure concurrency defaults to 1.
574 1 : assert_eq!(spec.reconfigure_concurrency, 1);
575 1 : }
576 :
577 : #[test]
578 1 : fn parse_unknown_fields() {
579 1 : // Forward compatibility test
580 1 : let file = File::open("tests/cluster_spec.json").unwrap();
581 1 : let mut json: serde_json::Value = serde_json::from_reader(file).unwrap();
582 1 : let ob = json.as_object_mut().unwrap();
583 1 : ob.insert("unknown_field_123123123".into(), "hello".into());
584 1 : let _spec: ComputeSpec = serde_json::from_value(json).unwrap();
585 1 : }
586 :
587 : #[test]
588 1 : fn parse_unknown_features() {
589 1 : // Test that unknown feature flags do not cause any errors.
590 1 : let file = File::open("tests/cluster_spec.json").unwrap();
591 1 : let mut json: serde_json::Value = serde_json::from_reader(file).unwrap();
592 1 : let ob = json.as_object_mut().unwrap();
593 1 :
594 1 : // Add unknown feature flags.
595 1 : let features = vec!["foo_bar_feature", "baz_feature"];
596 1 : ob.insert("features".into(), features.into());
597 1 :
598 1 : let spec: ComputeSpec = serde_json::from_value(json).unwrap();
599 1 :
600 1 : assert!(spec.features.len() == 2);
601 1 : assert!(spec.features.contains(&ComputeFeature::UnknownFeature));
602 1 : assert_eq!(spec.features, vec![ComputeFeature::UnknownFeature; 2]);
603 1 : }
604 :
605 : #[test]
606 1 : fn parse_known_features() {
607 1 : // Test that we can properly parse known feature flags.
608 1 : let file = File::open("tests/cluster_spec.json").unwrap();
609 1 : let mut json: serde_json::Value = serde_json::from_reader(file).unwrap();
610 1 : let ob = json.as_object_mut().unwrap();
611 1 :
612 1 : // Add known feature flags.
613 1 : let features = vec!["activity_monitor_experimental"];
614 1 : ob.insert("features".into(), features.into());
615 1 :
616 1 : let spec: ComputeSpec = serde_json::from_value(json).unwrap();
617 1 :
618 1 : assert_eq!(
619 1 : spec.features,
620 1 : vec![ComputeFeature::ActivityMonitorExperimental]
621 1 : );
622 1 : }
623 : }
|