|
| 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 | + |
| 18 | +use std::collections::HashMap; |
| 19 | + |
| 20 | +use opendal::services::HdfsNativeConfig; |
| 21 | +use opendal::Operator; |
| 22 | +use url::Url; |
| 23 | + |
| 24 | +use crate::error::Error; |
| 25 | +use crate::Result; |
| 26 | + |
| 27 | +/// Parse HDFS path to get relative path from root. |
| 28 | +/// |
| 29 | +/// Example: "hdfs://namenode:8020/warehouse/db/table" -> "warehouse/db/table" |
| 30 | +pub(crate) fn hdfs_relative_path(path: &str) -> Result<&str> { |
| 31 | + let after_scheme = path |
| 32 | + .strip_prefix("hdfs://") |
| 33 | + .ok_or_else(|| Error::ConfigInvalid { |
| 34 | + message: format!("Invalid HDFS path: {path}, should start with hdfs://"), |
| 35 | + })?; |
| 36 | + match after_scheme.find('/') { |
| 37 | + Some(pos) => Ok(&after_scheme[pos + 1..]), |
| 38 | + None => Err(Error::ConfigInvalid { |
| 39 | + message: format!("Invalid HDFS path: {path}, missing path component"), |
| 40 | + }), |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/// Configuration key for HDFS name node URL. |
| 45 | +/// |
| 46 | +/// Example: "hdfs://namenode:8020" or "hdfs://nameservice1" (HA). |
| 47 | +const HDFS_NAME_NODE: &str = "hdfs.name-node"; |
| 48 | + |
| 49 | +/// Configuration key to enable HDFS append capability. |
| 50 | +const HDFS_ENABLE_APPEND: &str = "hdfs.enable-append"; |
| 51 | + |
| 52 | +/// Parse paimon catalog options into an [`HdfsNativeConfig`]. |
| 53 | +/// |
| 54 | +/// Extracts HDFS-related configuration from the properties map. |
| 55 | +/// The `hdfs.name-node` key is optional — if omitted, the name node |
| 56 | +/// will be extracted from the file path URL at operator build time. |
| 57 | +pub(crate) fn hdfs_config_parse(props: HashMap<String, String>) -> Result<HdfsNativeConfig> { |
| 58 | + let mut cfg = HdfsNativeConfig::default(); |
| 59 | + |
| 60 | + cfg.name_node = props.get(HDFS_NAME_NODE).cloned(); |
| 61 | + |
| 62 | + if let Some(v) = props.get(HDFS_ENABLE_APPEND) { |
| 63 | + if v.eq_ignore_ascii_case("true") { |
| 64 | + cfg.enable_append = true; |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + Ok(cfg) |
| 69 | +} |
| 70 | + |
| 71 | +/// Build an [`Operator`] for the given HDFS path. |
| 72 | +/// |
| 73 | +/// If the config has no `name_node` set, it will be extracted from the path URL. |
| 74 | +/// The root is set to "/" so that relative paths work correctly. |
| 75 | +/// |
| 76 | +/// Example path: "hdfs://namenode:8020/warehouse/db/table" |
| 77 | +pub(crate) fn hdfs_config_build(cfg: &HdfsNativeConfig, path: &str) -> Result<Operator> { |
| 78 | + let url = Url::parse(path).map_err(|_| Error::ConfigInvalid { |
| 79 | + message: format!("Invalid HDFS url: {path}"), |
| 80 | + })?; |
| 81 | + |
| 82 | + let mut cfg = cfg.clone(); |
| 83 | + |
| 84 | + if cfg.name_node.is_none() { |
| 85 | + let host = url.host_str().ok_or_else(|| Error::ConfigInvalid { |
| 86 | + message: format!("Invalid HDFS url: {path}, missing name node host"), |
| 87 | + })?; |
| 88 | + let port_part = url.port().map(|p| format!(":{p}")).unwrap_or_default(); |
| 89 | + cfg.name_node = Some(format!("hdfs://{host}{port_part}")); |
| 90 | + } |
| 91 | + |
| 92 | + cfg.root = Some("/".to_string()); |
| 93 | + |
| 94 | + Ok(Operator::from_config(cfg)?.finish()) |
| 95 | +} |
| 96 | + |
| 97 | +#[cfg(test)] |
| 98 | +mod tests { |
| 99 | + use super::*; |
| 100 | + |
| 101 | + fn make_props(pairs: &[(&str, &str)]) -> HashMap<String, String> { |
| 102 | + pairs |
| 103 | + .iter() |
| 104 | + .map(|(k, v)| (k.to_string(), v.to_string())) |
| 105 | + .collect() |
| 106 | + } |
| 107 | + |
| 108 | + #[test] |
| 109 | + fn test_hdfs_config_parse_with_name_node() { |
| 110 | + let props = make_props(&[("hdfs.name-node", "hdfs://namenode:8020")]); |
| 111 | + let cfg = hdfs_config_parse(props).unwrap(); |
| 112 | + assert_eq!(cfg.name_node.as_deref(), Some("hdfs://namenode:8020")); |
| 113 | + assert!(!cfg.enable_append); |
| 114 | + } |
| 115 | + |
| 116 | + #[test] |
| 117 | + fn test_hdfs_config_parse_with_enable_append() { |
| 118 | + let props = make_props(&[ |
| 119 | + ("hdfs.name-node", "hdfs://namenode:8020"), |
| 120 | + ("hdfs.enable-append", "true"), |
| 121 | + ]); |
| 122 | + let cfg = hdfs_config_parse(props).unwrap(); |
| 123 | + assert!(cfg.enable_append); |
| 124 | + } |
| 125 | + |
| 126 | + #[test] |
| 127 | + fn test_hdfs_config_parse_empty_props() { |
| 128 | + let cfg = hdfs_config_parse(HashMap::new()).unwrap(); |
| 129 | + assert!(cfg.name_node.is_none()); |
| 130 | + assert!(!cfg.enable_append); |
| 131 | + } |
| 132 | + |
| 133 | + #[test] |
| 134 | + fn test_hdfs_config_build_extracts_name_node_from_path() { |
| 135 | + let cfg = HdfsNativeConfig::default(); |
| 136 | + let op = hdfs_config_build(&cfg, "hdfs://namenode:8020/warehouse/db").unwrap(); |
| 137 | + assert_eq!(op.info().scheme().to_string(), "hdfs-native"); |
| 138 | + } |
| 139 | + |
| 140 | + #[test] |
| 141 | + fn test_hdfs_config_build_uses_config_name_node() { |
| 142 | + let mut cfg = HdfsNativeConfig::default(); |
| 143 | + cfg.name_node = Some("hdfs://my-cluster:9000".to_string()); |
| 144 | + let op = hdfs_config_build(&cfg, "hdfs://my-cluster:9000/warehouse").unwrap(); |
| 145 | + assert_eq!(op.info().scheme().to_string(), "hdfs-native"); |
| 146 | + } |
| 147 | + |
| 148 | + #[test] |
| 149 | + fn test_hdfs_config_build_invalid_url() { |
| 150 | + let cfg = HdfsNativeConfig::default(); |
| 151 | + let result = hdfs_config_build(&cfg, "not-a-valid-url"); |
| 152 | + assert!(result.is_err()); |
| 153 | + } |
| 154 | + |
| 155 | + #[test] |
| 156 | + fn test_hdfs_config_build_missing_host() { |
| 157 | + let cfg = HdfsNativeConfig::default(); |
| 158 | + let result = hdfs_config_build(&cfg, "hdfs:///path/without/host"); |
| 159 | + assert!(result.is_err()); |
| 160 | + } |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn test_hdfs_config_parse_enable_append_false() { |
| 164 | + let props = make_props(&[ |
| 165 | + ("hdfs.name-node", "hdfs://namenode:8020"), |
| 166 | + ("hdfs.enable-append", "false"), |
| 167 | + ]); |
| 168 | + let cfg = hdfs_config_parse(props).unwrap(); |
| 169 | + assert!(!cfg.enable_append); |
| 170 | + } |
| 171 | + |
| 172 | + #[test] |
| 173 | + fn test_hdfs_config_parse_unrelated_keys_ignored() { |
| 174 | + let props = make_props(&[ |
| 175 | + ("s3.endpoint", "https://s3.amazonaws.com"), |
| 176 | + ("fs.oss.endpoint", "https://oss.aliyuncs.com"), |
| 177 | + ("hdfs.name-node", "hdfs://namenode:8020"), |
| 178 | + ]); |
| 179 | + let cfg = hdfs_config_parse(props).unwrap(); |
| 180 | + assert_eq!(cfg.name_node.as_deref(), Some("hdfs://namenode:8020")); |
| 181 | + } |
| 182 | + |
| 183 | + #[test] |
| 184 | + fn test_hdfs_relative_path_normal() { |
| 185 | + let result = hdfs_relative_path("hdfs://namenode:8020/warehouse/db/table"); |
| 186 | + assert_eq!(result.unwrap(), "warehouse/db/table"); |
| 187 | + } |
| 188 | + |
| 189 | + #[test] |
| 190 | + fn test_hdfs_relative_path_root_slash() { |
| 191 | + let result = hdfs_relative_path("hdfs://namenode:8020/"); |
| 192 | + assert_eq!(result.unwrap(), ""); |
| 193 | + } |
| 194 | + |
| 195 | + #[test] |
| 196 | + fn test_hdfs_relative_path_no_port() { |
| 197 | + let result = hdfs_relative_path("hdfs://nameservice1/warehouse/data"); |
| 198 | + assert_eq!(result.unwrap(), "warehouse/data"); |
| 199 | + } |
| 200 | + |
| 201 | + #[test] |
| 202 | + fn test_hdfs_relative_path_missing_path_component() { |
| 203 | + let result = hdfs_relative_path("hdfs://namenode:8020"); |
| 204 | + assert!(result.is_err()); |
| 205 | + } |
| 206 | + |
| 207 | + #[test] |
| 208 | + fn test_hdfs_relative_path_wrong_scheme() { |
| 209 | + let result = hdfs_relative_path("s3://bucket/key"); |
| 210 | + assert!(result.is_err()); |
| 211 | + } |
| 212 | +} |
0 commit comments