Skip to main content

opendal_service_goosefs/
backend.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::fmt::Debug;
19use std::sync::Arc;
20
21use log::debug;
22
23use super::GOOSEFS_SCHEME;
24use super::config::GoosefsConfig;
25use super::core::GoosefsCore;
26use super::deleter::GoosefsDeleter;
27use super::lister::GoosefsLister;
28use super::reader::*;
29use super::writer::GoosefsWriter;
30use super::writer::GoosefsWriters;
31use opendal_core::raw::*;
32use opendal_core::*;
33
34/// [GooseFS](https://cloud.tencent.com/product/goosefs) services support via native gRPC.
35#[doc = include_str!("docs.md")]
36#[derive(Default)]
37pub struct GoosefsBuilder {
38    pub(super) config: GoosefsConfig,
39}
40
41impl Debug for GoosefsBuilder {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("GoosefsBuilder")
44            .field("config", &self.config)
45            .finish_non_exhaustive()
46    }
47}
48
49impl GoosefsBuilder {
50    /// Set root of this backend.
51    ///
52    /// All operations will happen under this root.
53    pub fn root(mut self, root: &str) -> Self {
54        self.config.root = if root.is_empty() {
55            None
56        } else {
57            Some(root.to_string())
58        };
59        self
60    }
61
62    /// Set master address(es).
63    ///
64    /// Single master: `"10.0.0.1:9200"`
65    /// HA (comma-separated): `"10.0.0.1:9200,10.0.0.2:9200,10.0.0.3:9200"`
66    ///
67    /// This is the lowest-priority source: `build()` uses it only when
68    /// neither `GOOSEFS_MASTER_ADDR` nor `goosefs-site.properties` declares a
69    /// master address, and fails with `ConfigInvalid` when no source supplies
70    /// one. See [`crate::GoosefsConfig::master_addr`] for the full resolution
71    /// order and its rationale.
72    pub fn master_addr(mut self, addr: &str) -> Self {
73        if !addr.is_empty() {
74            self.config.master_addr = Some(addr.to_string());
75        }
76        self
77    }
78
79    /// Set block size for new files (bytes).
80    pub fn block_size(mut self, size: u64) -> Self {
81        self.config.block_size = Some(size);
82        self
83    }
84
85    /// Set chunk size for streaming RPCs (bytes).
86    pub fn chunk_size(mut self, size: u64) -> Self {
87        self.config.chunk_size = Some(size);
88        self
89    }
90
91    /// Set default write type.
92    ///
93    /// Values: `"must_cache"`, `"try_cache"`, `"cache_through"`, `"through"`,
94    /// `"async_through"`. Matching is case-insensitive. `build()` fails with
95    /// [`ErrorKind::ConfigInvalid`] when the value is not one of these.
96    pub fn write_type(mut self, wt: &str) -> Self {
97        if !wt.is_empty() {
98            self.config.write_type = Some(wt.to_string());
99        }
100        self
101    }
102
103    /// Set authentication type.
104    ///
105    /// Values: `"nosasl"`, `"simple"` (default: `"simple"`).
106    /// - `"nosasl"` — skip SASL authentication entirely.
107    /// - `"simple"` — PLAIN SASL with username (server does not verify password).
108    pub fn auth_type(mut self, auth_type: &str) -> Self {
109        if !auth_type.is_empty() {
110            self.config.auth_type = Some(auth_type.to_string());
111        }
112        self
113    }
114
115    /// Set authentication username.
116    ///
117    /// Used in SIMPLE mode as the login identity.
118    /// Default: current OS user (`$USER` / `$USERNAME`).
119    pub fn auth_username(mut self, username: &str) -> Self {
120        if !username.is_empty() {
121            self.config.auth_username = Some(username.to_string());
122        }
123        self
124    }
125}
126
127/// The source that supplied the master addresses of an auto-loaded SDK config.
128///
129/// `goosefs_sdk::config::GoosefsConfig::from_properties_auto()` always returns
130/// a master address: when neither `GOOSEFS_MASTER_ADDR` nor
131/// `goosefs-site.properties` declares one, it keeps the SDK default
132/// `127.0.0.1:9200`. The loaded value therefore cannot tell "a real source
133/// configured this" from "nothing configured this", and the builder has to
134/// ask which source actually spoke.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136enum MasterAddrSource {
137    /// `GOOSEFS_MASTER_ADDR` supplied the addresses.
138    Env,
139    /// `goosefs-site.properties` declared `goosefs.master.rpc.addresses` or
140    /// `goosefs.master.hostname`.
141    SiteProperties,
142    /// Neither spoke; the loaded addresses are the SDK defaults.
143    Unset,
144}
145
146/// Apply the master-address resolution order to an auto-loaded SDK config.
147///
148/// `goosefs-site.properties` carries a deployment's whole HA master list,
149/// which a URI authority such as `goosefs://host:9200/path` cannot express, so
150/// a site file that declares masters outranks `explicit` (the `master_addr`
151/// config key, whether it arrived from the builder, the option map, or the URI
152/// authority). `GOOSEFS_MASTER_ADDR` stays on top as the per-process override:
153///
154/// ```text
155/// GOOSEFS_MASTER_ADDR  >  goosefs-site.properties  >  master_addr
156/// ```
157///
158/// `from_properties_auto()` already resolved the first two sources, so
159/// `explicit` is written into `config` only when neither spoke. Returns
160/// `ConfigInvalid` when `explicit` holds no address after trimming, and when
161/// no source at all supplies one.
162fn apply_master_addr_precedence(
163    config: &mut goosefs_sdk::config::GoosefsConfig,
164    explicit: Option<&str>,
165) -> Result<()> {
166    let explicit_addrs = match explicit {
167        Some(master_addr) => {
168            let addrs: Vec<String> = master_addr
169                .split(',')
170                .map(|s| s.trim().to_string())
171                .filter(|s| !s.is_empty())
172                .collect();
173            if addrs.is_empty() {
174                return Err(Error::new(
175                    ErrorKind::ConfigInvalid,
176                    "master_addr is empty after trimming",
177                )
178                .with_operation("Builder::build")
179                .with_context("service", GOOSEFS_SCHEME));
180            }
181            Some(addrs)
182        }
183        None => None,
184    };
185
186    match detect_master_addr_source() {
187        MasterAddrSource::Unset => {
188            let Some(addrs) = explicit_addrs else {
189                return Err(Error::new(
190                    ErrorKind::ConfigInvalid,
191                    "master_addr is not configured: set it via GoosefsBuilder::master_addr(...), \
192                     the `master_addr` config key, the GOOSEFS_MASTER_ADDR env var, \
193                     or `goosefs.master.hostname`/`goosefs.master.rpc.addresses` in goosefs-site.properties",
194                )
195                .with_operation("Builder::build")
196                .with_context("service", GOOSEFS_SCHEME));
197            };
198
199            config.master_addr = addrs[0].clone();
200            config.master_addrs = if addrs.len() > 1 { addrs } else { Vec::new() };
201        }
202        source => {
203            if let Some(addrs) = explicit_addrs {
204                debug!(
205                    "GoosefsBuilder ignores master_addr {addrs:?}: {source:?} supplies {} (addrs={:?})",
206                    config.master_addr, config.master_addrs
207                );
208            }
209        }
210    }
211
212    Ok(())
213}
214
215/// Report which source supplied the master addresses that
216/// `from_properties_auto()` loaded.
217fn detect_master_addr_source() -> MasterAddrSource {
218    // Probe `GOOSEFS_MASTER_ADDR` through the SDK's own parser so that the
219    // `gfs://h1:9200,h2:9200/root` URI form and the bare comma list are
220    // recognised exactly as `from_properties_auto()` recognises them. Blanking
221    // the address first makes an unset, empty, or malformed value observable:
222    // in those cases `apply_env()` leaves the field untouched.
223    let probe = goosefs_sdk::config::GoosefsConfig {
224        master_addr: String::new(),
225        master_addrs: Vec::new(),
226        ..Default::default()
227    }
228    .apply_env();
229    if !probe.master_addr.is_empty() || !probe.master_addrs.is_empty() {
230        return MasterAddrSource::Env;
231    }
232
233    // The SDK searches `$GOOSEFS_CONFIG_FILE`, `$GOOSEFS_CONF_DIR`,
234    // `$GOOSEFS_HOME/conf`, `~/.goosefs`, and `/etc/goosefs` for
235    // `goosefs-site.properties`. An unreadable file is left to
236    // `from_properties_auto()`, which already failed the build above.
237    if let Some(path) = goosefs_sdk::config::discover_config_file()
238        && let Ok(content) = std::fs::read_to_string(&path)
239        && site_properties_declare_master(&content)
240    {
241        return MasterAddrSource::SiteProperties;
242    }
243
244    MasterAddrSource::Unset
245}
246
247/// Report whether `goosefs-site.properties` declares a master address.
248///
249/// The SDK's properties parser is private, so key lookup repeats its
250/// documented Java `Properties.load()` rules here: `#` and `!` start a comment
251/// line, the first `=` (else the first `:`) separates key from value, and the
252/// last assignment of a key wins. `goosefs.master.rpc.addresses` shadows
253/// `goosefs.master.hostname` whenever it is present, matching the order in
254/// which the SDK consults the two keys.
255fn site_properties_declare_master(content: &str) -> bool {
256    let mut addresses = None;
257    let mut hostname = None;
258
259    for line in content.lines() {
260        let line = line.trim();
261        if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
262            continue;
263        }
264        let Some(sep) = line.find('=').or_else(|| line.find(':')) else {
265            continue;
266        };
267
268        match line[..sep].trim() {
269            "goosefs.master.rpc.addresses" => addresses = Some(line[sep + 1..].trim()),
270            "goosefs.master.hostname" => hostname = Some(line[sep + 1..].trim()),
271            _ => {}
272        }
273    }
274
275    match addresses {
276        Some(addresses) => addresses.split(',').any(|addr| !addr.trim().is_empty()),
277        None => hostname.is_some_and(|hostname| !hostname.is_empty()),
278    }
279}
280
281impl Builder for GoosefsBuilder {
282    type Config = GoosefsConfig;
283
284    /// Build the backend and return a GoosefsBackend.
285    fn build(self) -> Result<impl Service> {
286        debug!("GoosefsBuilder::build started: {:?}", self);
287
288        let root = normalize_root(&self.config.root.clone().unwrap_or_default());
289        debug!("GoosefsBuilder use root {}", root);
290
291        // ── Step 1: establish the base SDK config ─────────────────────────────
292        //
293        // We follow the same priority chain that `FileSystemContext::connect`
294        // (and its `ConfigRefresher`) uses — see
295        // https://github.com/Tencent/tencent-goosefs-rust-sdk/blob/main/docs/CLIENT_CONFIGURATION.md
296        // §1 "Configuration Loading Priority":
297        //
298        //   defaults  <  goosefs-site.properties  <  GOOSEFS_* env vars
299        //
300        // `GoosefsConfig::from_properties_auto()` already implements this
301        // chain and is the *same* function the SDK calls every 60s to refresh
302        // the transparent-acceleration switches. Using it here keeps the
303        // initial OpenDAL build and the in-process hot-reload semantically
304        // aligned — users who deploy `goosefs-site.properties` get the exact
305        // same config from both paths.
306        //
307        // Failure policy:
308        //   * no properties file found  → silently uses defaults + env
309        //     (`from_properties_auto` handles this internally)
310        //   * properties file present but malformed → hard-fail
311        //     (broken config must not be silently dropped)
312        //
313        // Builder-explicit fields overlay this config afterwards. The master
314        // address is the one exception: Step 2 keeps the properties/env value
315        // on top, see `apply_master_addr_precedence`.
316        let mut goosefs_config = goosefs_sdk::config::GoosefsConfig::from_properties_auto()
317            .map_err(|e| {
318                Error::new(
319                    ErrorKind::ConfigInvalid,
320                    format!("failed to auto-load goosefs config: {e}"),
321                )
322                .with_operation("Builder::build")
323                .with_context("service", GOOSEFS_SCHEME)
324            })?;
325
326        // Root always comes from OpenDAL (it's an OpenDAL-layer concept).
327        goosefs_config.root = root.clone();
328
329        // ── Step 2: resolve the master address ────────────────────────────────
330        apply_master_addr_precedence(&mut goosefs_config, self.config.master_addr.as_deref())?;
331        debug!(
332            "GoosefsBuilder use master_addr {} (addrs={:?})",
333            goosefs_config.master_addr, goosefs_config.master_addrs
334        );
335
336        // ── Step 3: overlay the remaining builder-explicit fields ─────────────
337
338        if let Some(block_size) = self.config.block_size {
339            goosefs_config.block_size = block_size;
340        }
341        if let Some(chunk_size) = self.config.chunk_size {
342            goosefs_config.chunk_size = chunk_size;
343        }
344
345        // Parse write_type through the SDK so unknown values fail instead of
346        // silently becoming MUST_CACHE. `with_write_type_str` is
347        // case-insensitive, matching GooseFS `WritePType::valueOf`.
348        if let Some(ref wt) = self.config.write_type {
349            goosefs_config = goosefs_config.with_write_type_str(wt).map_err(|e| {
350                Error::new(
351                    ErrorKind::ConfigInvalid,
352                    format!("invalid write_type: {}", e),
353                )
354                .with_operation("Builder::build")
355                .with_context("service", GOOSEFS_SCHEME)
356            })?;
357        }
358
359        // Parse auth_type string → goosefs_sdk::auth::AuthType
360        if let Some(ref auth_type_str) = self.config.auth_type {
361            goosefs_config = goosefs_config
362                .with_auth_type_str(auth_type_str)
363                .map_err(|e| {
364                    Error::new(
365                        ErrorKind::ConfigInvalid,
366                        format!("invalid auth_type: {}", e),
367                    )
368                    .with_operation("Builder::build")
369                    .with_context("service", GOOSEFS_SCHEME)
370                })?;
371        }
372
373        if let Some(ref auth_username) = self.config.auth_username {
374            goosefs_config = goosefs_config.with_auth_username(auth_username);
375        }
376
377        // ── Step 4: validate the final merged config ──────────────────────────
378        goosefs_config.validate().map_err(|e| {
379            Error::new(
380                ErrorKind::ConfigInvalid,
381                format!("invalid goosefs config: {e}"),
382            )
383            .with_operation("Builder::build")
384            .with_context("service", GOOSEFS_SCHEME)
385        })?;
386
387        Ok(GoosefsBackend {
388            core: Arc::new(GoosefsCore::new(
389                ServiceInfo::new(GOOSEFS_SCHEME, &root, ""),
390                Capability {
391                    stat: true,
392                    read: true,
393                    write: true,
394                    write_can_multi: true,
395                    // Authoritative Create: write-via-temp then
396                    // GoosefsCore::rename(..., if_not_exists=true), backed by Master
397                    // no-replace rename. Not CreateFile on the final path
398                    // (writes go to .opendal.tmp.*).
399                    write_with_if_not_exists: true,
400                    create_dir: true,
401                    delete: true,
402                    list: true,
403                    rename: true,
404                    rename_with_if_not_exists: true,
405                    shared: true,
406                    ..Default::default()
407                },
408                root,
409                goosefs_config,
410            )),
411        })
412    }
413}
414
415#[derive(Debug, Clone)]
416pub struct GoosefsBackend {
417    pub(crate) core: Arc<GoosefsCore>,
418}
419
420impl Service for GoosefsBackend {
421    type Reader = oio::StreamReader<GoosefsReader>;
422    type Writer = GoosefsWriters;
423    type Lister = oio::PageLister<GoosefsLister>;
424    type Deleter = oio::OneShotDeleter<GoosefsDeleter>;
425    type Copier = ();
426    type Composer = ();
427
428    fn info(&self) -> ServiceInfo {
429        self.core.info.clone()
430    }
431
432    fn capability(&self) -> Capability {
433        self.core.capability
434    }
435
436    async fn create_dir(
437        &self,
438        _ctx: &OperationContext,
439        path: &str,
440        _: OpCreateDir,
441    ) -> Result<RpCreateDir> {
442        self.core.create_dir(path).await?;
443        Ok(RpCreateDir::default())
444    }
445
446    async fn stat(&self, _ctx: &OperationContext, path: &str, _: OpStat) -> Result<RpStat> {
447        let file_info = self.core.get_status(path).await?;
448        Ok(RpStat::new(self.core.file_info_to_metadata(&file_info)?))
449    }
450    fn read(&self, _ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
451        let output: oio::StreamReader<GoosefsReader> = {
452            Ok(oio::StreamReader::new(GoosefsReader::new(
453                self.clone(),
454                path,
455                args,
456            )))
457        }?;
458
459        Ok(output)
460    }
461
462    fn write(&self, _ctx: &OperationContext, path: &str, args: OpWrite) -> Result<Self::Writer> {
463        let output: GoosefsWriters = {
464            let w = GoosefsWriter::new(self.core.clone(), args.clone(), path.to_string());
465            Ok(w)
466        }?;
467
468        Ok(output)
469    }
470
471    fn delete(&self, _ctx: &OperationContext) -> Result<Self::Deleter> {
472        let output: oio::OneShotDeleter<GoosefsDeleter> = {
473            Ok(oio::OneShotDeleter::new(GoosefsDeleter::new(
474                self.core.clone(),
475            )))
476        }?;
477
478        Ok(output)
479    }
480
481    fn list(&self, _ctx: &OperationContext, path: &str, _args: OpList) -> Result<Self::Lister> {
482        let output: oio::PageLister<GoosefsLister> = {
483            let l = GoosefsLister::new(self.core.clone(), path);
484            Ok(oio::PageLister::new(l))
485        }?;
486
487        Ok(output)
488    }
489
490    fn copy(
491        &self,
492        _ctx: &OperationContext,
493        _from: &str,
494        _to: &str,
495        _args: OpCopy,
496    ) -> Result<Self::Copier> {
497        Err(Error::new(
498            ErrorKind::Unsupported,
499            "operation is not supported",
500        ))
501    }
502
503    async fn rename(
504        &self,
505        _ctx: &OperationContext,
506        from: &str,
507        to: &str,
508        args: OpRename,
509    ) -> Result<RpRename> {
510        self.core.rename(from, to, args.if_not_exists()).await?;
511        Ok(RpRename::default())
512    }
513
514    async fn presign(
515        &self,
516        _ctx: &OperationContext,
517        _path: &str,
518        _args: OpPresign,
519    ) -> Result<RpPresign> {
520        Err(Error::new(
521            ErrorKind::Unsupported,
522            "operation is not supported",
523        ))
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use goosefs_sdk::config::ENV_CONF_DIR;
531    use goosefs_sdk::config::ENV_CONFIG_FILE;
532    use goosefs_sdk::config::ENV_HOME;
533    use goosefs_sdk::config::ENV_MASTER_ADDR;
534    use std::path::PathBuf;
535    use std::sync::Mutex;
536
537    /// `GOOSEFS_*` vars are process-global; serialize every test that
538    /// exercises master-address resolution through them.
539    static ENV_LOCK: Mutex<()> = Mutex::new(());
540
541    const SITE_HA: &str =
542        "goosefs.master.rpc.addresses=172.31.5.10:9200,172.31.5.2:9200,172.31.5.11:9200\n";
543    const SITE_NO_MASTER: &str = "goosefs.user.block.size.bytes.default=4MB\n";
544
545    /// Write a `goosefs-site.properties` fixture, point `$GOOSEFS_CONFIG_FILE`
546    /// at it, and clear the other resolution inputs.
547    ///
548    /// A file is written even for the "no master keys" cases so that discovery
549    /// stops at `$GOOSEFS_CONFIG_FILE` instead of reaching a `~/.goosefs` or
550    /// `/etc/goosefs` file that happens to exist on the host.
551    fn site_properties(name: &str, content: &str) -> PathBuf {
552        let path = std::env::temp_dir().join(format!(
553            "opendal_goosefs_{}_{name}_site.properties",
554            std::process::id()
555        ));
556        std::fs::write(&path, content).expect("write goosefs-site.properties");
557
558        unsafe {
559            std::env::set_var(ENV_CONFIG_FILE, &path);
560            std::env::remove_var(ENV_CONF_DIR);
561            std::env::remove_var(ENV_HOME);
562            std::env::remove_var(ENV_MASTER_ADDR);
563        }
564
565        path
566    }
567
568    fn clear_site_properties(path: &PathBuf) {
569        unsafe {
570            std::env::remove_var(ENV_CONFIG_FILE);
571            std::env::remove_var(ENV_MASTER_ADDR);
572        }
573        let _ = std::fs::remove_file(path);
574    }
575
576    /// Resolve master addresses exactly as `build()` does: auto-load, then
577    /// apply the precedence.
578    fn resolve(explicit: Option<&str>) -> Result<(String, Vec<String>)> {
579        let mut config = goosefs_sdk::config::GoosefsConfig::from_properties_auto()
580            .expect("auto-load must succeed");
581        apply_master_addr_precedence(&mut config, explicit)?;
582        Ok((config.master_addr, config.master_addrs))
583    }
584
585    /// A site file that declares `goosefs.master.rpc.addresses` supplies the
586    /// HA list even when the URI authority carries an unreachable address.
587    ///
588    /// This is the reported failure: with `GOOSEFS_CONFIG_FILE` pointing at a
589    /// file that lists the real masters, the client still dialed the dummy
590    /// address from `goosefs://192.0.2.5:9999/...`.
591    #[test]
592    fn site_properties_outrank_uri_authority() {
593        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
594        let path = site_properties("site_over_uri", SITE_HA);
595
596        let (master_addr, master_addrs) =
597            resolve(Some("192.0.2.5:9999")).expect("resolution must succeed");
598
599        assert_eq!(master_addr, "172.31.5.10:9200");
600        assert_eq!(
601            master_addrs,
602            vec![
603                "172.31.5.10:9200".to_string(),
604                "172.31.5.2:9200".to_string(),
605                "172.31.5.11:9200".to_string(),
606            ]
607        );
608
609        clear_site_properties(&path);
610    }
611
612    /// Without a URI authority or `GOOSEFS_MASTER_ADDR`, a declared site file
613    /// still supplies the masters — `goosefs:///path` must not be rejected for
614    /// a missing host.
615    #[test]
616    fn site_properties_resolve_without_explicit_addr() {
617        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
618        let path = site_properties("site_only", SITE_HA);
619
620        let (master_addr, master_addrs) = resolve(None).expect("resolution must succeed");
621
622        assert_eq!(master_addr, "172.31.5.10:9200");
623        assert_eq!(master_addrs.len(), 3);
624
625        clear_site_properties(&path);
626    }
627
628    /// `GOOSEFS_MASTER_ADDR` is the per-process override and outranks both the
629    /// site file and the URI authority.
630    #[test]
631    fn env_master_addr_outranks_site_properties_and_uri_authority() {
632        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
633        let path = site_properties("env_over_site", SITE_HA);
634        unsafe { std::env::set_var(ENV_MASTER_ADDR, "10.0.0.7:9200") };
635
636        let (master_addr, master_addrs) =
637            resolve(Some("192.0.2.5:9999")).expect("resolution must succeed");
638
639        assert_eq!(master_addr, "10.0.0.7:9200");
640        assert!(
641            master_addrs.is_empty(),
642            "a single env address must not populate the HA list, got {master_addrs:?}"
643        );
644
645        clear_site_properties(&path);
646    }
647
648    /// A site file without master keys leaves `master_addr` in charge, so a
649    /// URI authority or option-map address is used as before.
650    #[test]
651    fn explicit_addr_applies_when_no_source_declares_masters() {
652        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
653        let path = site_properties("explicit_only", SITE_NO_MASTER);
654
655        let (master_addr, master_addrs) =
656            resolve(Some("10.0.0.1:9200,10.0.0.2:9200")).expect("resolution must succeed");
657
658        assert_eq!(master_addr, "10.0.0.1:9200");
659        assert_eq!(
660            master_addrs,
661            vec!["10.0.0.1:9200".to_string(), "10.0.0.2:9200".to_string()]
662        );
663
664        clear_site_properties(&path);
665    }
666
667    /// When no source declares a master address, `build()` must fail instead
668    /// of silently dialing the SDK default `127.0.0.1:9200`.
669    #[test]
670    fn missing_master_addr_fails() {
671        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
672        let path = site_properties("missing_master", SITE_NO_MASTER);
673
674        let err = resolve(None).expect_err("resolution must fail without any master address");
675        assert_eq!(err.kind(), ErrorKind::ConfigInvalid);
676        assert!(
677            err.to_string().contains("master_addr is not configured"),
678            "unexpected error message: {err}"
679        );
680
681        clear_site_properties(&path);
682    }
683
684    /// `GOOSEFS_MASTER_ADDR` also accepts the SDK's `gfs://` URI form, and
685    /// resolution must recognise it as a real source rather than falling
686    /// through to `master_addr`.
687    #[test]
688    fn env_master_addr_accepts_gfs_uri_form() {
689        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
690        let path = site_properties("env_gfs_uri", SITE_NO_MASTER);
691        unsafe { std::env::set_var(ENV_MASTER_ADDR, "gfs://10.0.0.1:9200,10.0.0.2:9200/lance") };
692
693        let (master_addr, master_addrs) =
694            resolve(Some("192.0.2.5:9999")).expect("resolution must succeed");
695
696        assert_eq!(master_addr, "10.0.0.1:9200");
697        assert_eq!(master_addrs.len(), 2);
698
699        clear_site_properties(&path);
700    }
701
702    #[test]
703    fn site_properties_declare_master_reads_both_keys() {
704        assert!(site_properties_declare_master(
705            "goosefs.master.rpc.addresses=10.0.0.1:9200,10.0.0.2:9200\n"
706        ));
707        assert!(site_properties_declare_master(
708            "goosefs.master.hostname = master-1\ngoosefs.master.rpc.port=9200\n"
709        ));
710        // Java `Properties.load()` also accepts `:` as the separator.
711        assert!(site_properties_declare_master(
712            "goosefs.master.hostname:master-1\n"
713        ));
714    }
715
716    #[test]
717    fn site_properties_declare_master_rejects_absent_and_blank_keys() {
718        assert!(!site_properties_declare_master(SITE_NO_MASTER));
719        assert!(!site_properties_declare_master(
720            "# goosefs.master.rpc.addresses=10.0.0.1:9200\n! goosefs.master.hostname=master-1\n"
721        ));
722        assert!(!site_properties_declare_master(
723            "goosefs.master.hostname=\n"
724        ));
725        assert!(!site_properties_declare_master(
726            "goosefs.master.rpc.addresses= , ,\n"
727        ));
728        // The SDK consults `addresses` first and never falls back to
729        // `hostname` once the key is present, so a blank list declares
730        // nothing here either.
731        assert!(!site_properties_declare_master(
732            "goosefs.master.rpc.addresses=\ngoosefs.master.hostname=master-1\n"
733        ));
734    }
735
736    #[test]
737    fn test_builder_build() {
738        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
739        let builder = GoosefsBuilder::default()
740            .root("/data")
741            .master_addr("127.0.0.1:9200")
742            .build();
743        assert!(builder.is_ok());
744    }
745
746    #[test]
747    fn test_builder_ha() {
748        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
749        let builder = GoosefsBuilder::default()
750            .root("/data")
751            .master_addr("10.0.0.1:9200,10.0.0.2:9200,10.0.0.3:9200")
752            .build();
753        assert!(builder.is_ok());
754    }
755
756    /// `master_addr` is mandatory — `build()` must fail with `ConfigInvalid`
757    /// when it cannot be resolved from any source. This test exercises the
758    /// "explicitly blank" form (empty / whitespace / comma-only), which is
759    /// environment-independent: Step 2 short-circuits on a blank override
760    /// before any auto-load value can rescue it.
761    #[test]
762    fn test_builder_blank_master_addr_fails() {
763        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
764        let err = GoosefsBuilder::default()
765            .root("/data")
766            .master_addr("   ,  , ")
767            .build()
768            .expect_err("build must fail when master_addr is blank");
769        assert_eq!(err.kind(), ErrorKind::ConfigInvalid);
770        assert!(
771            err.to_string().contains("master_addr is empty"),
772            "unexpected error message: {err}"
773        );
774    }
775
776    #[test]
777    fn test_builder_unknown_write_type_fails() {
778        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
779        for wt in ["INVALID_WT", "cache_throughh"] {
780            let err = GoosefsBuilder::default()
781                .root("/data")
782                .master_addr("127.0.0.1:9200")
783                .write_type(wt)
784                .build()
785                .expect_err("build must fail on unknown write_type");
786            assert_eq!(err.kind(), ErrorKind::ConfigInvalid);
787            assert!(
788                err.to_string().contains("write_type"),
789                "unexpected error message for {wt}: {err}"
790            );
791        }
792    }
793
794    #[test]
795    fn test_builder_write_type_is_case_insensitive() {
796        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
797        let backend = GoosefsBuilder::default()
798            .root("/data")
799            .master_addr("127.0.0.1:9200")
800            .write_type("CACHE_THROUGH")
801            .build();
802        assert!(backend.is_ok());
803    }
804
805    #[test]
806    fn test_capability_rename_with_if_not_exists() {
807        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
808        let backend = GoosefsBuilder::default()
809            .root("/data")
810            .master_addr("127.0.0.1:9200")
811            .build()
812            .expect("build");
813        let cap = backend.capability();
814        assert!(cap.write_with_if_not_exists);
815        assert!(
816            cap.rename_with_if_not_exists,
817            "rename_with_if_not_exists must be declared for Create publish"
818        );
819    }
820}