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 122071 : #[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 attachment_service. 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 attachment service. If set, this will be propagated into the
81 : // attachment service'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 19727 : #[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 6573 : fn default() -> Self {
104 6573 : NeonBroker {
105 6573 : listen_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0),
106 6573 : }
107 6573 : }
108 : }
109 :
110 : impl NeonBroker {
111 1510 : pub fn client_url(&self) -> Url {
112 1510 : Url::parse(&format!("http://{}", self.listen_addr)).expect("failed to construct url")
113 1510 : }
114 : }
115 :
116 85558 : #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
117 : #[serde(default)]
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 :
131 : impl Default for PageServerConf {
132 7778 : fn default() -> Self {
133 7778 : Self {
134 7778 : id: NodeId(0),
135 7778 : listen_pg_addr: String::new(),
136 7778 : listen_http_addr: String::new(),
137 7778 : pg_auth_type: AuthType::Trust,
138 7778 : http_auth_type: AuthType::Trust,
139 7778 : }
140 7778 : }
141 : }
142 :
143 108569 : #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
144 : #[serde(default)]
145 : pub struct SafekeeperConf {
146 : pub id: NodeId,
147 : pub pg_port: u16,
148 : pub pg_tenant_only_port: Option<u16>,
149 : pub http_port: u16,
150 : pub sync: bool,
151 : pub remote_storage: Option<String>,
152 : pub backup_threads: Option<u32>,
153 : pub auth_enabled: bool,
154 : }
155 :
156 : impl Default for SafekeeperConf {
157 8361 : fn default() -> Self {
158 8361 : Self {
159 8361 : id: NodeId(0),
160 8361 : pg_port: 0,
161 8361 : pg_tenant_only_port: None,
162 8361 : http_port: 0,
163 8361 : sync: true,
164 8361 : remote_storage: None,
165 8361 : backup_threads: None,
166 8361 : auth_enabled: false,
167 8361 : }
168 8361 : }
169 : }
170 :
171 710 : #[derive(Clone, Copy)]
172 : pub enum InitForceMode {
173 : MustNotExist,
174 : EmptyDirOk,
175 : RemoveAllContents,
176 : }
177 :
178 : impl ValueEnum for InitForceMode {
179 1066 : fn value_variants<'a>() -> &'a [Self] {
180 1066 : &[
181 1066 : Self::MustNotExist,
182 1066 : Self::EmptyDirOk,
183 1066 : Self::RemoveAllContents,
184 1066 : ]
185 1066 : }
186 :
187 7886 : fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
188 7886 : Some(clap::builder::PossibleValue::new(match self {
189 7174 : InitForceMode::MustNotExist => "must-not-exist",
190 356 : InitForceMode::EmptyDirOk => "empty-dir-ok",
191 356 : InitForceMode::RemoveAllContents => "remove-all-contents",
192 : }))
193 7886 : }
194 : }
195 :
196 : impl SafekeeperConf {
197 : /// Compute is served by port on which only tenant scoped tokens allowed, if
198 : /// it is configured.
199 1287 : pub fn get_compute_port(&self) -> u16 {
200 1287 : self.pg_tenant_only_port.unwrap_or(self.pg_port)
201 1287 : }
202 : }
203 :
204 : impl LocalEnv {
205 993 : pub fn pg_distrib_dir_raw(&self) -> PathBuf {
206 993 : self.pg_distrib_dir.clone()
207 993 : }
208 :
209 3394 : pub fn pg_distrib_dir(&self, pg_version: u32) -> anyhow::Result<PathBuf> {
210 3394 : let path = self.pg_distrib_dir.clone();
211 3394 :
212 3394 : #[allow(clippy::manual_range_patterns)]
213 3394 : match pg_version {
214 3394 : 14 | 15 | 16 => Ok(path.join(format!("v{pg_version}"))),
215 0 : _ => bail!("Unsupported postgres version: {}", pg_version),
216 : }
217 3394 : }
218 :
219 2242 : pub fn pg_bin_dir(&self, pg_version: u32) -> anyhow::Result<PathBuf> {
220 2242 : Ok(self.pg_distrib_dir(pg_version)?.join("bin"))
221 2242 : }
222 1152 : pub fn pg_lib_dir(&self, pg_version: u32) -> anyhow::Result<PathBuf> {
223 1152 : Ok(self.pg_distrib_dir(pg_version)?.join("lib"))
224 1152 : }
225 :
226 993 : pub fn pageserver_bin(&self) -> PathBuf {
227 993 : self.neon_distrib_dir.join("pageserver")
228 993 : }
229 :
230 361 : pub fn attachment_service_bin(&self) -> PathBuf {
231 361 : // Irrespective of configuration, attachment service binary is always
232 361 : // run from the same location as neon_local. This means that for compatibility
233 361 : // tests that run old pageserver/safekeeper, they still run latest attachment service.
234 361 : let neon_local_bin_dir = env::current_exe().unwrap().parent().unwrap().to_owned();
235 361 : neon_local_bin_dir.join("attachment_service")
236 361 : }
237 :
238 513 : pub fn safekeeper_bin(&self) -> PathBuf {
239 513 : self.neon_distrib_dir.join("safekeeper")
240 513 : }
241 :
242 3 : pub fn storage_broker_bin(&self) -> PathBuf {
243 3 : self.neon_distrib_dir.join("storage_broker")
244 3 : }
245 :
246 12244 : pub fn endpoints_path(&self) -> PathBuf {
247 12244 : self.base_data_dir.join("endpoints")
248 12244 : }
249 :
250 2203 : pub fn pageserver_data_dir(&self, pageserver_id: NodeId) -> PathBuf {
251 2203 : self.base_data_dir
252 2203 : .join(format!("pageserver_{pageserver_id}"))
253 2203 : }
254 :
255 2509 : pub fn safekeeper_data_dir(&self, data_dir_name: &str) -> PathBuf {
256 2509 : self.base_data_dir.join("safekeepers").join(data_dir_name)
257 2509 : }
258 :
259 2521 : pub fn get_pageserver_conf(&self, id: NodeId) -> anyhow::Result<&PageServerConf> {
260 2967 : if let Some(conf) = self.pageservers.iter().find(|node| node.id == id) {
261 2520 : Ok(conf)
262 : } else {
263 1 : let have_ids = self
264 1 : .pageservers
265 1 : .iter()
266 2 : .map(|node| format!("{}:{}", node.id, node.listen_http_addr))
267 1 : .collect::<Vec<_>>();
268 1 : let joined = have_ids.join(",");
269 1 : bail!("could not find pageserver {id}, have ids {joined}")
270 : }
271 2521 : }
272 :
273 797 : pub fn register_branch_mapping(
274 797 : &mut self,
275 797 : branch_name: String,
276 797 : tenant_id: TenantId,
277 797 : timeline_id: TimelineId,
278 797 : ) -> anyhow::Result<()> {
279 797 : let existing_values = self
280 797 : .branch_name_mappings
281 797 : .entry(branch_name.clone())
282 797 : .or_default();
283 797 :
284 797 : let existing_ids = existing_values
285 797 : .iter()
286 797 : .find(|(existing_tenant_id, _)| existing_tenant_id == &tenant_id);
287 :
288 797 : if let Some((_, old_timeline_id)) = existing_ids {
289 1 : if old_timeline_id == &timeline_id {
290 1 : Ok(())
291 : } else {
292 0 : bail!("branch '{branch_name}' is already mapped to timeline {old_timeline_id}, cannot map to another timeline {timeline_id}");
293 : }
294 : } else {
295 796 : existing_values.push((tenant_id, timeline_id));
296 796 : Ok(())
297 : }
298 797 : }
299 :
300 794 : pub fn get_branch_timeline_id(
301 794 : &self,
302 794 : branch_name: &str,
303 794 : tenant_id: TenantId,
304 794 : ) -> Option<TimelineId> {
305 794 : self.branch_name_mappings
306 794 : .get(branch_name)?
307 794 : .iter()
308 908 : .find(|(mapped_tenant_id, _)| mapped_tenant_id == &tenant_id)
309 794 : .map(|&(_, timeline_id)| timeline_id)
310 794 : .map(TimelineId::from)
311 794 : }
312 :
313 19 : pub fn timeline_name_mappings(&self) -> HashMap<TenantTimelineId, String> {
314 19 : self.branch_name_mappings
315 19 : .iter()
316 36 : .flat_map(|(name, tenant_timelines)| {
317 38 : tenant_timelines.iter().map(|&(tenant_id, timeline_id)| {
318 38 : (TenantTimelineId::new(tenant_id, timeline_id), name.clone())
319 38 : })
320 36 : })
321 19 : .collect()
322 19 : }
323 :
324 : /// Create a LocalEnv from a config file.
325 : ///
326 : /// Unlike 'load_config', this function fills in any defaults that are missing
327 : /// from the config file.
328 358 : pub fn parse_config(toml: &str) -> anyhow::Result<Self> {
329 358 : let mut env: LocalEnv = toml::from_str(toml)?;
330 :
331 : // Find postgres binaries.
332 : // Follow POSTGRES_DISTRIB_DIR if set, otherwise look in "pg_install".
333 : // Note that later in the code we assume, that distrib dirs follow the same pattern
334 : // for all postgres versions.
335 356 : if env.pg_distrib_dir == Path::new("") {
336 356 : if let Some(postgres_bin) = env::var_os("POSTGRES_DISTRIB_DIR") {
337 354 : env.pg_distrib_dir = postgres_bin.into();
338 354 : } else {
339 2 : let cwd = env::current_dir()?;
340 2 : env.pg_distrib_dir = cwd.join("pg_install")
341 : }
342 0 : }
343 :
344 : // Find neon binaries.
345 356 : if env.neon_distrib_dir == Path::new("") {
346 356 : env.neon_distrib_dir = env::current_exe()?.parent().unwrap().to_owned();
347 0 : }
348 :
349 356 : if env.pageservers.is_empty() {
350 0 : anyhow::bail!("Configuration must contain at least one pageserver");
351 356 : }
352 356 :
353 356 : env.base_data_dir = base_path();
354 356 :
355 356 : Ok(env)
356 358 : }
357 :
358 : /// Locate and load config
359 6217 : pub fn load_config() -> anyhow::Result<Self> {
360 6217 : let repopath = base_path();
361 6217 :
362 6217 : if !repopath.exists() {
363 0 : bail!(
364 0 : "Neon config is not found in {}. You need to run 'neon_local init' first",
365 0 : repopath.to_str().unwrap()
366 0 : );
367 6217 : }
368 :
369 : // TODO: check that it looks like a neon repository
370 :
371 : // load and parse file
372 6217 : let config = fs::read_to_string(repopath.join("config"))?;
373 6217 : let mut env: LocalEnv = toml::from_str(config.as_str())?;
374 :
375 6217 : env.base_data_dir = repopath;
376 6217 :
377 6217 : Ok(env)
378 6217 : }
379 :
380 1504 : pub fn persist_config(&self, base_path: &Path) -> anyhow::Result<()> {
381 1504 : // Currently, the user first passes a config file with 'neon_local init --config=<path>'
382 1504 : // We read that in, in `create_config`, and fill any missing defaults. Then it's saved
383 1504 : // to .neon/config. TODO: We lose any formatting and comments along the way, which is
384 1504 : // a bit sad.
385 1504 : let mut conf_content = r#"# This file describes a local deployment of the page server
386 1504 : # and safekeeeper node. It is read by the 'neon_local' command-line
387 1504 : # utility.
388 1504 : "#
389 1504 : .to_string();
390 1504 :
391 1504 : // Convert the LocalEnv to a toml file.
392 1504 : //
393 1504 : // This could be as simple as this:
394 1504 : //
395 1504 : // conf_content += &toml::to_string_pretty(env)?;
396 1504 : //
397 1504 : // But it results in a "values must be emitted before tables". I'm not sure
398 1504 : // why, AFAICS the table, i.e. 'safekeepers: Vec<SafekeeperConf>' is last.
399 1504 : // Maybe rust reorders the fields to squeeze avoid padding or something?
400 1504 : // In any case, converting to toml::Value first, and serializing that, works.
401 1504 : // See https://github.com/alexcrichton/toml-rs/issues/142
402 1504 : conf_content += &toml::to_string_pretty(&toml::Value::try_from(self)?)?;
403 :
404 1504 : let target_config_path = base_path.join("config");
405 1504 : fs::write(&target_config_path, conf_content).with_context(|| {
406 0 : format!(
407 0 : "Failed to write config file into path '{}'",
408 0 : target_config_path.display()
409 0 : )
410 1504 : })
411 1504 : }
412 :
413 : // this function is used only for testing purposes in CLI e g generate tokens during init
414 184 : pub fn generate_auth_token(&self, claims: &Claims) -> anyhow::Result<String> {
415 184 : let private_key_path = if self.private_key_path.is_absolute() {
416 0 : self.private_key_path.to_path_buf()
417 : } else {
418 184 : self.base_data_dir.join(&self.private_key_path)
419 : };
420 :
421 184 : let key_data = fs::read(private_key_path)?;
422 184 : encode_from_key_file(claims, &key_data)
423 184 : }
424 :
425 : //
426 : // Initialize a new Neon repository
427 : //
428 354 : pub fn init(&mut self, pg_version: u32, force: &InitForceMode) -> anyhow::Result<()> {
429 354 : // check if config already exists
430 354 : let base_path = &self.base_data_dir;
431 354 : ensure!(
432 354 : base_path != Path::new(""),
433 0 : "repository base path is missing"
434 : );
435 :
436 354 : if base_path.exists() {
437 0 : match force {
438 : InitForceMode::MustNotExist => {
439 0 : bail!(
440 0 : "directory '{}' already exists. Perhaps already initialized?",
441 0 : base_path.display()
442 0 : );
443 : }
444 : InitForceMode::EmptyDirOk => {
445 0 : if let Some(res) = std::fs::read_dir(base_path)?.next() {
446 0 : res.context("check if directory is empty")?;
447 0 : anyhow::bail!("directory not empty: {base_path:?}");
448 0 : }
449 : }
450 : InitForceMode::RemoveAllContents => {
451 0 : println!("removing all contents of '{}'", base_path.display());
452 : // instead of directly calling `remove_dir_all`, we keep the original dir but removing
453 : // all contents inside. This helps if the developer symbol links another directory (i.e.,
454 : // S3 local SSD) to the `.neon` base directory.
455 0 : for entry in std::fs::read_dir(base_path)? {
456 0 : let entry = entry?;
457 0 : let path = entry.path();
458 0 : if path.is_dir() {
459 0 : fs::remove_dir_all(&path)?;
460 : } else {
461 0 : fs::remove_file(&path)?;
462 : }
463 : }
464 : }
465 : }
466 354 : }
467 :
468 354 : if !self.pg_bin_dir(pg_version)?.join("postgres").exists() {
469 0 : bail!(
470 0 : "Can't find postgres binary at {}",
471 0 : self.pg_bin_dir(pg_version)?.display()
472 : );
473 354 : }
474 708 : for binary in ["pageserver", "safekeeper"] {
475 708 : if !self.neon_distrib_dir.join(binary).exists() {
476 0 : bail!(
477 0 : "Can't find binary '{binary}' in neon distrib dir '{}'",
478 0 : self.neon_distrib_dir.display()
479 0 : );
480 708 : }
481 : }
482 :
483 354 : if !base_path.exists() {
484 354 : fs::create_dir(base_path)?;
485 0 : }
486 :
487 : // Generate keypair for JWT.
488 : //
489 : // The keypair is only needed if authentication is enabled in any of the
490 : // components. For convenience, we generate the keypair even if authentication
491 : // is not enabled, so that you can easily enable it after the initialization
492 : // step. However, if the key generation fails, we treat it as non-fatal if
493 : // authentication was not enabled.
494 354 : if self.private_key_path == PathBuf::new() {
495 354 : match generate_auth_keys(
496 354 : base_path.join("auth_private_key.pem").as_path(),
497 354 : base_path.join("auth_public_key.pem").as_path(),
498 354 : ) {
499 354 : Ok(()) => {
500 354 : self.private_key_path = PathBuf::from("auth_private_key.pem");
501 354 : }
502 0 : Err(e) => {
503 0 : if !self.auth_keys_needed() {
504 0 : eprintln!("Could not generate keypair for JWT authentication: {e}");
505 0 : eprintln!("Continuing anyway because authentication was not enabled");
506 0 : self.private_key_path = PathBuf::from("auth_private_key.pem");
507 0 : } else {
508 0 : return Err(e);
509 : }
510 : }
511 : }
512 0 : }
513 :
514 354 : fs::create_dir_all(self.endpoints_path())?;
515 :
516 774 : for safekeeper in &self.safekeepers {
517 420 : fs::create_dir_all(SafekeeperNode::datadir_path_by_id(self, safekeeper.id))?;
518 : }
519 :
520 354 : self.persist_config(base_path)
521 354 : }
522 :
523 0 : fn auth_keys_needed(&self) -> bool {
524 0 : self.pageservers.iter().any(|ps| {
525 0 : ps.pg_auth_type == AuthType::NeonJWT || ps.http_auth_type == AuthType::NeonJWT
526 0 : }) || self.safekeepers.iter().any(|sk| sk.auth_enabled)
527 0 : }
528 : }
529 :
530 6573 : fn base_path() -> PathBuf {
531 6573 : match std::env::var_os("NEON_REPO_DIR") {
532 6571 : Some(val) => PathBuf::from(val),
533 2 : None => PathBuf::from(".neon"),
534 : }
535 6573 : }
536 :
537 : /// Generate a public/private key pair for JWT authentication
538 354 : fn generate_auth_keys(private_key_path: &Path, public_key_path: &Path) -> anyhow::Result<()> {
539 : // Generate the key pair
540 : //
541 : // openssl genpkey -algorithm ed25519 -out auth_private_key.pem
542 354 : let keygen_output = Command::new("openssl")
543 354 : .arg("genpkey")
544 354 : .args(["-algorithm", "ed25519"])
545 354 : .args(["-out", private_key_path.to_str().unwrap()])
546 354 : .stdout(Stdio::null())
547 354 : .output()
548 354 : .context("failed to generate auth private key")?;
549 354 : if !keygen_output.status.success() {
550 0 : bail!(
551 0 : "openssl failed: '{}'",
552 0 : String::from_utf8_lossy(&keygen_output.stderr)
553 0 : );
554 354 : }
555 : // Extract the public key from the private key file
556 : //
557 : // openssl pkey -in auth_private_key.pem -pubout -out auth_public_key.pem
558 354 : let keygen_output = Command::new("openssl")
559 354 : .arg("pkey")
560 354 : .args(["-in", private_key_path.to_str().unwrap()])
561 354 : .arg("-pubout")
562 354 : .args(["-out", public_key_path.to_str().unwrap()])
563 354 : .output()
564 354 : .context("failed to extract public key from private key")?;
565 354 : if !keygen_output.status.success() {
566 0 : bail!(
567 0 : "openssl failed: '{}'",
568 0 : String::from_utf8_lossy(&keygen_output.stderr)
569 0 : );
570 354 : }
571 354 : Ok(())
572 354 : }
573 :
574 : #[cfg(test)]
575 : mod tests {
576 : use super::*;
577 :
578 2 : #[test]
579 2 : fn simple_conf_parsing() {
580 2 : let simple_conf_toml = include_str!("../simple.conf");
581 2 : let simple_conf_parse_result = LocalEnv::parse_config(simple_conf_toml);
582 2 : assert!(
583 2 : simple_conf_parse_result.is_ok(),
584 0 : "failed to parse simple config {simple_conf_toml}, reason: {simple_conf_parse_result:?}"
585 : );
586 :
587 2 : let string_to_replace = "listen_addr = '127.0.0.1:50051'";
588 2 : let spoiled_url_str = "listen_addr = '!@$XOXO%^&'";
589 2 : let spoiled_url_toml = simple_conf_toml.replace(string_to_replace, spoiled_url_str);
590 2 : assert!(
591 2 : spoiled_url_toml.contains(spoiled_url_str),
592 0 : "Failed to replace string {string_to_replace} in the toml file {simple_conf_toml}"
593 : );
594 2 : let spoiled_url_parse_result = LocalEnv::parse_config(&spoiled_url_toml);
595 2 : assert!(
596 2 : spoiled_url_parse_result.is_err(),
597 0 : "expected toml with invalid Url {spoiled_url_toml} to fail the parsing, but got {spoiled_url_parse_result:?}"
598 : );
599 2 : }
600 : }
|