Line data Source code
1 : //! This module is responsible for locating and loading paths in a local setup.
2 : //!
3 : //! Now it also provides init method which acts like a stub for proper installation
4 : //! script which will use local paths.
5 :
6 : use anyhow::{bail, ensure, Context};
7 :
8 : use clap::ValueEnum;
9 : use postgres_backend::AuthType;
10 : use reqwest::Url;
11 : use serde::{Deserialize, Serialize};
12 : use std::collections::HashMap;
13 : use std::env;
14 : use std::fs;
15 : use std::net::IpAddr;
16 : use std::net::Ipv4Addr;
17 : use std::net::SocketAddr;
18 : use std::path::{Path, PathBuf};
19 : use std::process::{Command, Stdio};
20 : use utils::{
21 : auth::{encode_from_key_file, Claims},
22 : id::{NodeId, TenantId, TenantTimelineId, TimelineId},
23 : };
24 :
25 : use crate::safekeeper::SafekeeperNode;
26 :
27 : pub const DEFAULT_PG_VERSION: u32 = 15;
28 :
29 : //
30 : // This data structures represents neon_local CLI config
31 : //
32 : // It is deserialized from the .neon/config file, or the config file passed
33 : // to 'neon_local init --config=<path>' option. See control_plane/simple.conf for
34 : // an example.
35 : //
36 30 : #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
37 : pub struct LocalEnv {
38 : // Base directory for all the nodes (the pageserver, safekeepers and
39 : // compute endpoints).
40 : //
41 : // This is not stored in the config file. Rather, this is the path where the
42 : // config file itself is. It is read from the NEON_REPO_DIR env variable or
43 : // '.neon' if not given.
44 : #[serde(skip)]
45 : pub base_data_dir: PathBuf,
46 :
47 : // Path to postgres distribution. It's expected that "bin", "include",
48 : // "lib", "share" from postgres distribution are there. If at some point
49 : // in time we will be able to run against vanilla postgres we may split that
50 : // to four separate paths and match OS-specific installation layout.
51 : #[serde(default)]
52 : pub pg_distrib_dir: PathBuf,
53 :
54 : // Path to pageserver binary.
55 : #[serde(default)]
56 : pub neon_distrib_dir: PathBuf,
57 :
58 : // Default tenant ID to use with the 'neon_local' command line utility, when
59 : // --tenant_id is not explicitly specified.
60 : #[serde(default)]
61 : pub default_tenant_id: Option<TenantId>,
62 :
63 : // used to issue tokens during e.g pg start
64 : #[serde(default)]
65 : pub private_key_path: PathBuf,
66 :
67 : pub broker: NeonBroker,
68 :
69 : /// This Vec must always contain at least one pageserver
70 : pub pageservers: Vec<PageServerConf>,
71 :
72 : #[serde(default)]
73 : pub safekeepers: Vec<SafekeeperConf>,
74 :
75 : // Control plane upcall API for pageserver: if None, we will not run storage_controller If set, this will
76 : // be propagated into each pageserver's configuration.
77 : #[serde(default)]
78 : pub control_plane_api: Option<Url>,
79 :
80 : // Control plane upcall API for storage controller. If set, this will be propagated into the
81 : // storage controller's configuration.
82 : #[serde(default)]
83 : pub control_plane_compute_hook_api: Option<Url>,
84 :
85 : /// Keep human-readable aliases in memory (and persist them to config), to hide ZId hex strings from the user.
86 : #[serde(default)]
87 : // A `HashMap<String, HashMap<TenantId, TimelineId>>` would be more appropriate here,
88 : // but deserialization into a generic toml object as `toml::Value::try_from` fails with an error.
89 : // https://toml.io/en/v1.0.0 does not contain a concept of "a table inside another table".
90 : branch_name_mappings: HashMap<String, Vec<(TenantId, TimelineId)>>,
91 : }
92 :
93 : /// Broker config for cluster internal communication.
94 14 : #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
95 : #[serde(default)]
96 : pub struct NeonBroker {
97 : /// Broker listen address for storage nodes coordination, e.g. '127.0.0.1:50051'.
98 : pub listen_addr: SocketAddr,
99 : }
100 :
101 : // Dummy Default impl to satisfy Deserialize derive.
102 : impl Default for NeonBroker {
103 2 : fn default() -> Self {
104 2 : NeonBroker {
105 2 : listen_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0),
106 2 : }
107 2 : }
108 : }
109 :
110 : impl NeonBroker {
111 0 : pub fn client_url(&self) -> Url {
112 0 : Url::parse(&format!("http://{}", self.listen_addr)).expect("failed to construct url")
113 0 : }
114 : }
115 :
116 44 : #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
117 : #[serde(default, deny_unknown_fields)]
118 : pub struct PageServerConf {
119 : // node id
120 : pub id: NodeId,
121 :
122 : // Pageserver connection settings
123 : pub listen_pg_addr: String,
124 : pub listen_http_addr: String,
125 :
126 : // auth type used for the PG and HTTP ports
127 : pub pg_auth_type: AuthType,
128 : pub http_auth_type: AuthType,
129 :
130 : pub(crate) virtual_file_io_engine: Option<String>,
131 : pub(crate) get_vectored_impl: Option<String>,
132 : }
133 :
134 : impl Default for PageServerConf {
135 4 : fn default() -> Self {
136 4 : Self {
137 4 : id: NodeId(0),
138 4 : listen_pg_addr: String::new(),
139 4 : listen_http_addr: String::new(),
140 4 : pg_auth_type: AuthType::Trust,
141 4 : http_auth_type: AuthType::Trust,
142 4 : virtual_file_io_engine: None,
143 4 : get_vectored_impl: None,
144 4 : }
145 4 : }
146 : }
147 :
148 28 : #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
149 : #[serde(default)]
150 : pub struct SafekeeperConf {
151 : pub id: NodeId,
152 : pub pg_port: u16,
153 : pub pg_tenant_only_port: Option<u16>,
154 : pub http_port: u16,
155 : pub sync: bool,
156 : pub remote_storage: Option<String>,
157 : pub backup_threads: Option<u32>,
158 : pub auth_enabled: bool,
159 : pub listen_addr: Option<String>,
160 : }
161 :
162 : impl Default for SafekeeperConf {
163 4 : fn default() -> Self {
164 4 : Self {
165 4 : id: NodeId(0),
166 4 : pg_port: 0,
167 4 : pg_tenant_only_port: None,
168 4 : http_port: 0,
169 4 : sync: true,
170 4 : remote_storage: None,
171 4 : backup_threads: None,
172 4 : auth_enabled: false,
173 4 : listen_addr: None,
174 4 : }
175 4 : }
176 : }
177 :
178 : #[derive(Clone, Copy)]
179 : pub enum InitForceMode {
180 : MustNotExist,
181 : EmptyDirOk,
182 : RemoveAllContents,
183 : }
184 :
185 : impl ValueEnum for InitForceMode {
186 4 : fn value_variants<'a>() -> &'a [Self] {
187 4 : &[
188 4 : Self::MustNotExist,
189 4 : Self::EmptyDirOk,
190 4 : Self::RemoveAllContents,
191 4 : ]
192 4 : }
193 :
194 10 : fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
195 10 : Some(clap::builder::PossibleValue::new(match self {
196 6 : InitForceMode::MustNotExist => "must-not-exist",
197 2 : InitForceMode::EmptyDirOk => "empty-dir-ok",
198 2 : InitForceMode::RemoveAllContents => "remove-all-contents",
199 : }))
200 10 : }
201 : }
202 :
203 : impl SafekeeperConf {
204 : /// Compute is served by port on which only tenant scoped tokens allowed, if
205 : /// it is configured.
206 0 : pub fn get_compute_port(&self) -> u16 {
207 0 : self.pg_tenant_only_port.unwrap_or(self.pg_port)
208 0 : }
209 : }
210 :
211 : impl LocalEnv {
212 0 : pub fn pg_distrib_dir_raw(&self) -> PathBuf {
213 0 : self.pg_distrib_dir.clone()
214 0 : }
215 :
216 0 : pub fn pg_distrib_dir(&self, pg_version: u32) -> anyhow::Result<PathBuf> {
217 0 : let path = self.pg_distrib_dir.clone();
218 0 :
219 0 : #[allow(clippy::manual_range_patterns)]
220 0 : match pg_version {
221 0 : 14 | 15 | 16 => Ok(path.join(format!("v{pg_version}"))),
222 0 : _ => bail!("Unsupported postgres version: {}", pg_version),
223 : }
224 0 : }
225 :
226 0 : pub fn pg_bin_dir(&self, pg_version: u32) -> anyhow::Result<PathBuf> {
227 0 : Ok(self.pg_distrib_dir(pg_version)?.join("bin"))
228 0 : }
229 0 : pub fn pg_lib_dir(&self, pg_version: u32) -> anyhow::Result<PathBuf> {
230 0 : Ok(self.pg_distrib_dir(pg_version)?.join("lib"))
231 0 : }
232 :
233 0 : pub fn pageserver_bin(&self) -> PathBuf {
234 0 : self.neon_distrib_dir.join("pageserver")
235 0 : }
236 :
237 0 : pub fn storage_controller_bin(&self) -> PathBuf {
238 0 : // Irrespective of configuration, storage controller binary is always
239 0 : // run from the same location as neon_local. This means that for compatibility
240 0 : // tests that run old pageserver/safekeeper, they still run latest storage controller.
241 0 : let neon_local_bin_dir = env::current_exe().unwrap().parent().unwrap().to_owned();
242 0 : neon_local_bin_dir.join("storage_controller")
243 0 : }
244 :
245 0 : pub fn safekeeper_bin(&self) -> PathBuf {
246 0 : self.neon_distrib_dir.join("safekeeper")
247 0 : }
248 :
249 0 : pub fn storage_broker_bin(&self) -> PathBuf {
250 0 : self.neon_distrib_dir.join("storage_broker")
251 0 : }
252 :
253 0 : pub fn endpoints_path(&self) -> PathBuf {
254 0 : self.base_data_dir.join("endpoints")
255 0 : }
256 :
257 0 : pub fn pageserver_data_dir(&self, pageserver_id: NodeId) -> PathBuf {
258 0 : self.base_data_dir
259 0 : .join(format!("pageserver_{pageserver_id}"))
260 0 : }
261 :
262 0 : pub fn safekeeper_data_dir(&self, data_dir_name: &str) -> PathBuf {
263 0 : self.base_data_dir.join("safekeepers").join(data_dir_name)
264 0 : }
265 :
266 0 : pub fn get_pageserver_conf(&self, id: NodeId) -> anyhow::Result<&PageServerConf> {
267 0 : if let Some(conf) = self.pageservers.iter().find(|node| node.id == id) {
268 0 : Ok(conf)
269 : } else {
270 0 : let have_ids = self
271 0 : .pageservers
272 0 : .iter()
273 0 : .map(|node| format!("{}:{}", node.id, node.listen_http_addr))
274 0 : .collect::<Vec<_>>();
275 0 : let joined = have_ids.join(",");
276 0 : bail!("could not find pageserver {id}, have ids {joined}")
277 : }
278 0 : }
279 :
280 0 : pub fn register_branch_mapping(
281 0 : &mut self,
282 0 : branch_name: String,
283 0 : tenant_id: TenantId,
284 0 : timeline_id: TimelineId,
285 0 : ) -> anyhow::Result<()> {
286 0 : let existing_values = self
287 0 : .branch_name_mappings
288 0 : .entry(branch_name.clone())
289 0 : .or_default();
290 0 :
291 0 : let existing_ids = existing_values
292 0 : .iter()
293 0 : .find(|(existing_tenant_id, _)| existing_tenant_id == &tenant_id);
294 :
295 0 : if let Some((_, old_timeline_id)) = existing_ids {
296 0 : if old_timeline_id == &timeline_id {
297 0 : Ok(())
298 : } else {
299 0 : bail!("branch '{branch_name}' is already mapped to timeline {old_timeline_id}, cannot map to another timeline {timeline_id}");
300 : }
301 : } else {
302 0 : existing_values.push((tenant_id, timeline_id));
303 0 : Ok(())
304 : }
305 0 : }
306 :
307 0 : pub fn get_branch_timeline_id(
308 0 : &self,
309 0 : branch_name: &str,
310 0 : tenant_id: TenantId,
311 0 : ) -> Option<TimelineId> {
312 0 : self.branch_name_mappings
313 0 : .get(branch_name)?
314 0 : .iter()
315 0 : .find(|(mapped_tenant_id, _)| mapped_tenant_id == &tenant_id)
316 0 : .map(|&(_, timeline_id)| timeline_id)
317 0 : .map(TimelineId::from)
318 0 : }
319 :
320 0 : pub fn timeline_name_mappings(&self) -> HashMap<TenantTimelineId, String> {
321 0 : self.branch_name_mappings
322 0 : .iter()
323 0 : .flat_map(|(name, tenant_timelines)| {
324 0 : tenant_timelines.iter().map(|&(tenant_id, timeline_id)| {
325 0 : (TenantTimelineId::new(tenant_id, timeline_id), name.clone())
326 0 : })
327 0 : })
328 0 : .collect()
329 0 : }
330 :
331 : /// Create a LocalEnv from a config file.
332 : ///
333 : /// Unlike 'load_config', this function fills in any defaults that are missing
334 : /// from the config file.
335 4 : pub fn parse_config(toml: &str) -> anyhow::Result<Self> {
336 4 : let mut env: LocalEnv = toml::from_str(toml)?;
337 :
338 : // Find postgres binaries.
339 : // Follow POSTGRES_DISTRIB_DIR if set, otherwise look in "pg_install".
340 : // Note that later in the code we assume, that distrib dirs follow the same pattern
341 : // for all postgres versions.
342 2 : if env.pg_distrib_dir == Path::new("") {
343 2 : if let Some(postgres_bin) = env::var_os("POSTGRES_DISTRIB_DIR") {
344 0 : env.pg_distrib_dir = postgres_bin.into();
345 0 : } else {
346 2 : let cwd = env::current_dir()?;
347 2 : env.pg_distrib_dir = cwd.join("pg_install")
348 : }
349 0 : }
350 :
351 : // Find neon binaries.
352 2 : if env.neon_distrib_dir == Path::new("") {
353 2 : env.neon_distrib_dir = env::current_exe()?.parent().unwrap().to_owned();
354 0 : }
355 :
356 2 : if env.pageservers.is_empty() {
357 0 : anyhow::bail!("Configuration must contain at least one pageserver");
358 2 : }
359 2 :
360 2 : env.base_data_dir = base_path();
361 2 :
362 2 : Ok(env)
363 4 : }
364 :
365 : /// Locate and load config
366 0 : pub fn load_config() -> anyhow::Result<Self> {
367 0 : let repopath = base_path();
368 0 :
369 0 : if !repopath.exists() {
370 0 : bail!(
371 0 : "Neon config is not found in {}. You need to run 'neon_local init' first",
372 0 : repopath.to_str().unwrap()
373 0 : );
374 0 : }
375 :
376 : // TODO: check that it looks like a neon repository
377 :
378 : // load and parse file
379 0 : let config = fs::read_to_string(repopath.join("config"))?;
380 0 : let mut env: LocalEnv = toml::from_str(config.as_str())?;
381 :
382 0 : env.base_data_dir = repopath;
383 0 :
384 0 : Ok(env)
385 0 : }
386 :
387 0 : pub fn persist_config(&self, base_path: &Path) -> anyhow::Result<()> {
388 0 : // Currently, the user first passes a config file with 'neon_local init --config=<path>'
389 0 : // We read that in, in `create_config`, and fill any missing defaults. Then it's saved
390 0 : // to .neon/config. TODO: We lose any formatting and comments along the way, which is
391 0 : // a bit sad.
392 0 : let mut conf_content = r#"# This file describes a local deployment of the page server
393 0 : # and safekeeeper node. It is read by the 'neon_local' command-line
394 0 : # utility.
395 0 : "#
396 0 : .to_string();
397 0 :
398 0 : // Convert the LocalEnv to a toml file.
399 0 : //
400 0 : // This could be as simple as this:
401 0 : //
402 0 : // conf_content += &toml::to_string_pretty(env)?;
403 0 : //
404 0 : // But it results in a "values must be emitted before tables". I'm not sure
405 0 : // why, AFAICS the table, i.e. 'safekeepers: Vec<SafekeeperConf>' is last.
406 0 : // Maybe rust reorders the fields to squeeze avoid padding or something?
407 0 : // In any case, converting to toml::Value first, and serializing that, works.
408 0 : // See https://github.com/alexcrichton/toml-rs/issues/142
409 0 : conf_content += &toml::to_string_pretty(&toml::Value::try_from(self)?)?;
410 :
411 0 : let target_config_path = base_path.join("config");
412 0 : fs::write(&target_config_path, conf_content).with_context(|| {
413 0 : format!(
414 0 : "Failed to write config file into path '{}'",
415 0 : target_config_path.display()
416 0 : )
417 0 : })
418 0 : }
419 :
420 : // this function is used only for testing purposes in CLI e g generate tokens during init
421 0 : pub fn generate_auth_token(&self, claims: &Claims) -> anyhow::Result<String> {
422 0 : let private_key_path = self.get_private_key_path();
423 0 : let key_data = fs::read(private_key_path)?;
424 0 : encode_from_key_file(claims, &key_data)
425 0 : }
426 :
427 0 : pub fn get_private_key_path(&self) -> PathBuf {
428 0 : if self.private_key_path.is_absolute() {
429 0 : self.private_key_path.to_path_buf()
430 : } else {
431 0 : self.base_data_dir.join(&self.private_key_path)
432 : }
433 0 : }
434 :
435 : //
436 : // Initialize a new Neon repository
437 : //
438 0 : pub fn init(&mut self, pg_version: u32, force: &InitForceMode) -> anyhow::Result<()> {
439 0 : // check if config already exists
440 0 : let base_path = &self.base_data_dir;
441 0 : ensure!(
442 0 : base_path != Path::new(""),
443 0 : "repository base path is missing"
444 : );
445 :
446 0 : if base_path.exists() {
447 0 : match force {
448 : InitForceMode::MustNotExist => {
449 0 : bail!(
450 0 : "directory '{}' already exists. Perhaps already initialized?",
451 0 : base_path.display()
452 0 : );
453 : }
454 : InitForceMode::EmptyDirOk => {
455 0 : if let Some(res) = std::fs::read_dir(base_path)?.next() {
456 0 : res.context("check if directory is empty")?;
457 0 : anyhow::bail!("directory not empty: {base_path:?}");
458 0 : }
459 : }
460 : InitForceMode::RemoveAllContents => {
461 0 : println!("removing all contents of '{}'", base_path.display());
462 : // instead of directly calling `remove_dir_all`, we keep the original dir but removing
463 : // all contents inside. This helps if the developer symbol links another directory (i.e.,
464 : // S3 local SSD) to the `.neon` base directory.
465 0 : for entry in std::fs::read_dir(base_path)? {
466 0 : let entry = entry?;
467 0 : let path = entry.path();
468 0 : if path.is_dir() {
469 0 : fs::remove_dir_all(&path)?;
470 : } else {
471 0 : fs::remove_file(&path)?;
472 : }
473 : }
474 : }
475 : }
476 0 : }
477 :
478 0 : if !self.pg_bin_dir(pg_version)?.join("postgres").exists() {
479 0 : bail!(
480 0 : "Can't find postgres binary at {}",
481 0 : self.pg_bin_dir(pg_version)?.display()
482 : );
483 0 : }
484 0 : for binary in ["pageserver", "safekeeper"] {
485 0 : if !self.neon_distrib_dir.join(binary).exists() {
486 0 : bail!(
487 0 : "Can't find binary '{binary}' in neon distrib dir '{}'",
488 0 : self.neon_distrib_dir.display()
489 0 : );
490 0 : }
491 : }
492 :
493 0 : if !base_path.exists() {
494 0 : fs::create_dir(base_path)?;
495 0 : }
496 :
497 : // Generate keypair for JWT.
498 : //
499 : // The keypair is only needed if authentication is enabled in any of the
500 : // components. For convenience, we generate the keypair even if authentication
501 : // is not enabled, so that you can easily enable it after the initialization
502 : // step. However, if the key generation fails, we treat it as non-fatal if
503 : // authentication was not enabled.
504 0 : if self.private_key_path == PathBuf::new() {
505 0 : match generate_auth_keys(
506 0 : base_path.join("auth_private_key.pem").as_path(),
507 0 : base_path.join("auth_public_key.pem").as_path(),
508 0 : ) {
509 0 : Ok(()) => {
510 0 : self.private_key_path = PathBuf::from("auth_private_key.pem");
511 0 : }
512 0 : Err(e) => {
513 0 : if !self.auth_keys_needed() {
514 0 : eprintln!("Could not generate keypair for JWT authentication: {e}");
515 0 : eprintln!("Continuing anyway because authentication was not enabled");
516 0 : self.private_key_path = PathBuf::from("auth_private_key.pem");
517 0 : } else {
518 0 : return Err(e);
519 : }
520 : }
521 : }
522 0 : }
523 :
524 0 : fs::create_dir_all(self.endpoints_path())?;
525 :
526 0 : for safekeeper in &self.safekeepers {
527 0 : fs::create_dir_all(SafekeeperNode::datadir_path_by_id(self, safekeeper.id))?;
528 : }
529 :
530 0 : self.persist_config(base_path)
531 0 : }
532 :
533 0 : fn auth_keys_needed(&self) -> bool {
534 0 : self.pageservers.iter().any(|ps| {
535 0 : ps.pg_auth_type == AuthType::NeonJWT || ps.http_auth_type == AuthType::NeonJWT
536 0 : }) || self.safekeepers.iter().any(|sk| sk.auth_enabled)
537 0 : }
538 : }
539 :
540 2 : fn base_path() -> PathBuf {
541 2 : match std::env::var_os("NEON_REPO_DIR") {
542 0 : Some(val) => PathBuf::from(val),
543 2 : None => PathBuf::from(".neon"),
544 : }
545 2 : }
546 :
547 : /// Generate a public/private key pair for JWT authentication
548 0 : fn generate_auth_keys(private_key_path: &Path, public_key_path: &Path) -> anyhow::Result<()> {
549 : // Generate the key pair
550 : //
551 : // openssl genpkey -algorithm ed25519 -out auth_private_key.pem
552 0 : let keygen_output = Command::new("openssl")
553 0 : .arg("genpkey")
554 0 : .args(["-algorithm", "ed25519"])
555 0 : .args(["-out", private_key_path.to_str().unwrap()])
556 0 : .stdout(Stdio::null())
557 0 : .output()
558 0 : .context("failed to generate auth private key")?;
559 0 : if !keygen_output.status.success() {
560 0 : bail!(
561 0 : "openssl failed: '{}'",
562 0 : String::from_utf8_lossy(&keygen_output.stderr)
563 0 : );
564 0 : }
565 : // Extract the public key from the private key file
566 : //
567 : // openssl pkey -in auth_private_key.pem -pubout -out auth_public_key.pem
568 0 : let keygen_output = Command::new("openssl")
569 0 : .arg("pkey")
570 0 : .args(["-in", private_key_path.to_str().unwrap()])
571 0 : .arg("-pubout")
572 0 : .args(["-out", public_key_path.to_str().unwrap()])
573 0 : .output()
574 0 : .context("failed to extract public key from private key")?;
575 0 : if !keygen_output.status.success() {
576 0 : bail!(
577 0 : "openssl failed: '{}'",
578 0 : String::from_utf8_lossy(&keygen_output.stderr)
579 0 : );
580 0 : }
581 0 : Ok(())
582 0 : }
583 :
584 : #[cfg(test)]
585 : mod tests {
586 : use super::*;
587 :
588 : #[test]
589 2 : fn simple_conf_parsing() {
590 2 : let simple_conf_toml = include_str!("../simple.conf");
591 2 : let simple_conf_parse_result = LocalEnv::parse_config(simple_conf_toml);
592 2 : assert!(
593 2 : simple_conf_parse_result.is_ok(),
594 0 : "failed to parse simple config {simple_conf_toml}, reason: {simple_conf_parse_result:?}"
595 : );
596 :
597 2 : let string_to_replace = "listen_addr = '127.0.0.1:50051'";
598 2 : let spoiled_url_str = "listen_addr = '!@$XOXO%^&'";
599 2 : let spoiled_url_toml = simple_conf_toml.replace(string_to_replace, spoiled_url_str);
600 2 : assert!(
601 2 : spoiled_url_toml.contains(spoiled_url_str),
602 0 : "Failed to replace string {string_to_replace} in the toml file {simple_conf_toml}"
603 : );
604 2 : let spoiled_url_parse_result = LocalEnv::parse_config(&spoiled_url_toml);
605 2 : assert!(
606 2 : spoiled_url_parse_result.is_err(),
607 0 : "expected toml with invalid Url {spoiled_url_toml} to fail the parsing, but got {spoiled_url_parse_result:?}"
608 : );
609 2 : }
610 : }
|