Skip to main content

tmprl_client/
conn.rs

1//! Connecting to a Temporal frontend.
2//!
3//! Profile resolution is delegated to `ClientOptions::load_from_config`, which is
4//! Temporal's own loader: it reads `~/.config/temporalio/temporal.toml`, applies the
5//! `TEMPORAL_*` environment variables over it, and resolves TLS material (including
6//! reading cert/key files off disk). Reimplementing that would only drift from what
7//! the `temporal` CLI does, so we don't.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use temporalio_client::{
13    Client, ClientOptions,
14    envconfig::{DataSource, LoadClientConfigProfileOptions},
15    grpc::{CloudService, OperatorService, WorkflowService},
16};
17
18/// Which profile to connect as.
19#[derive(Debug, Clone, Default)]
20pub struct ProfileRef {
21    /// Profile name from the TOML config. `None` uses `TEMPORAL_PROFILE`, else `default`.
22    pub name: Option<String>,
23    /// Override the config file path. `None` uses `TEMPORAL_CONFIG_FILE`, else the OS default.
24    pub config_file: Option<String>,
25}
26
27#[derive(Debug, thiserror::Error)]
28pub enum ConnectError {
29    /// `envconfig::ConfigError` boxes a `dyn Error` source that is not `Sync`, which makes
30    /// the whole error unusable across `anyhow` and tokio task boundaries. Flatten it here
31    /// so everything above this crate gets a `Send + Sync` error.
32    #[error("could not load Temporal profile: {0}")]
33    Config(String),
34    #[error("could not connect to Temporal: {0}")]
35    Connect(String),
36}
37
38/// Which file the connection profiles are read from.
39///
40/// The platform path is authoritative, because it is the one the `temporal` CLI uses:
41/// `$HOME/.config` on Unix, `$HOME/Library/Application Support` on macOS. Returning `None`
42/// lets the loader apply it, which also preserves the `TEMPORAL_CONFIG_FILE` step in the
43/// precedence chain.
44///
45/// `~/.config/temporalio/temporal.toml` is consulted only as a fallback, and only on a
46/// platform where it is not already the default. On macOS that directory is where people
47/// reasonably expect the file to live, and a config that is present but silently unread is
48/// a bad half hour; matching the CLI still wins whenever the CLI's own file exists.
49fn config_source(explicit: Option<&str>) -> Option<DataSource> {
50    if let Some(p) = explicit {
51        return Some(DataSource::Path(p.to_string()));
52    }
53    // Set: the loader reads it, and overriding here would silently outrank it.
54    if std::env::var_os("TEMPORAL_CONFIG_FILE").is_some_and(|v| !v.is_empty()) {
55        return None;
56    }
57    // The CLI's own file wins whenever it is there.
58    if platform_config_file().is_some_and(|p| p.is_file()) {
59        return None;
60    }
61    let path = xdg_config_file()?;
62    Path::new(&path)
63        .is_file()
64        .then(|| DataSource::Path(path.to_string_lossy().into_owned()))
65}
66
67/// `$XDG_CONFIG_HOME/temporalio/temporal.toml`, else `~/.config/temporalio/temporal.toml`.
68pub fn xdg_config_file() -> Option<PathBuf> {
69    let base = match std::env::var_os("XDG_CONFIG_HOME") {
70        Some(d) if !d.is_empty() => PathBuf::from(d),
71        _ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
72    };
73    Some(base.join("temporalio").join("temporal.toml"))
74}
75
76/// Where connection profiles will actually be read from, for `--config-path`.
77pub fn config_file_in_use(explicit: Option<&str>) -> Option<PathBuf> {
78    if let Some(p) = explicit {
79        return Some(PathBuf::from(p));
80    }
81    if let Some(p) = std::env::var_os("TEMPORAL_CONFIG_FILE").filter(|v| !v.is_empty()) {
82        return Some(PathBuf::from(p));
83    }
84    let platform = platform_config_file();
85    if platform.as_ref().is_some_and(|p| p.is_file()) {
86        return platform;
87    }
88    match xdg_config_file() {
89        Some(p) if p.is_file() => Some(p),
90        // Neither exists: name the platform path, since that is where it should be created.
91        _ => platform,
92    }
93}
94
95/// `temporal.toml` under the platform config directory, the path the CLI documents.
96pub fn platform_config_file() -> Option<PathBuf> {
97    dirs_config_dir().map(|d| d.join("temporalio").join("temporal.toml"))
98}
99
100/// The platform config directory `temporalio-common` itself uses.
101fn dirs_config_dir() -> Option<PathBuf> {
102    #[cfg(target_os = "macos")]
103    {
104        Some(PathBuf::from(std::env::var_os("HOME")?).join("Library/Application Support"))
105    }
106    #[cfg(not(target_os = "macos"))]
107    {
108        match std::env::var_os("XDG_CONFIG_HOME") {
109            Some(d) if !d.is_empty() => Some(PathBuf::from(d)),
110            _ => Some(PathBuf::from(std::env::var_os("HOME")?).join(".config")),
111        }
112    }
113}
114
115/// A live, namespace-bound connection. Cheap to clone, clones share one HTTP/2 channel,
116/// which is what makes multi-namespace fan-out cheap.
117#[derive(Clone)]
118pub struct Conn {
119    client: Client,
120    profile: Arc<str>,
121    namespace: Arc<str>,
122    address: Arc<str>,
123}
124
125impl Conn {
126    pub async fn connect(profile: &ProfileRef) -> Result<Self, ConnectError> {
127        let load = LoadClientConfigProfileOptions::builder()
128            .maybe_config_file_profile(profile.name.clone())
129            .maybe_config_source(config_source(profile.config_file.as_deref()))
130            .build();
131
132        let (conn_opts, client_opts) = ClientOptions::load_from_config(load)
133            .map_err(|e| ConnectError::Config(e.to_string()))?;
134        let namespace: Arc<str> = client_opts.namespace.as_str().into();
135        // Read before `connect` consumes the options. A namespace name is not unique
136        // across clusters, so the audit log needs the target to be readable later.
137        let address: Arc<str> = conn_opts.target.to_string().into();
138        let client = Client::connect(conn_opts, client_opts)
139            .await
140            .map_err(|e| ConnectError::Connect(e.to_string()))?;
141
142        Ok(Self {
143            client,
144            profile: profile.name.as_deref().unwrap_or("default").into(),
145            namespace,
146            address,
147        })
148    }
149
150    pub fn namespace(&self) -> &str {
151        &self.namespace
152    }
153
154    pub fn profile(&self) -> &str {
155        &self.profile
156    }
157
158    /// The frontend this is connected to, as a URL. Never carries credentials: an API key
159    /// lives in the connection options, not the target.
160    pub fn address(&self) -> &str {
161        &self.address
162    }
163
164    /// Raw `WorkflowService`. Requests take `tonic::Request<T>` and the connection's
165    /// retry policy is already applied underneath.
166    pub fn wf(&self) -> Box<dyn WorkflowService> {
167        self.client.connection().workflow_service()
168    }
169
170    pub fn operator(&self) -> Box<dyn OperatorService> {
171        self.client.connection().operator_service()
172    }
173
174    pub fn cloud(&self) -> Box<dyn CloudService> {
175        self.client.connection().cloud_service()
176    }
177
178    /// The high-level client, for the handful of operations where `temporalio-client`
179    /// already does the assembly work for us (schedules, workflow handles).
180    pub fn raw(&self) -> &Client {
181        &self.client
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn an_explicit_path_wins() {
191        // `--temporal-config` must outrank every discovery rule below it.
192        assert!(matches!(
193            config_source(Some("/tmp/explicit.toml")),
194            Some(DataSource::Path(p)) if p == "/tmp/explicit.toml"
195        ));
196        assert_eq!(
197            config_file_in_use(Some("/tmp/explicit.toml")),
198            Some(PathBuf::from("/tmp/explicit.toml"))
199        );
200    }
201
202    #[test]
203    fn the_platform_path_is_the_one_the_cli_documents() {
204        // `temporal config --help`: $HOME/.config on Unix, $HOME/Library/Application Support
205        // on macOS. tmprl must not disagree with the CLI about which file it is reading.
206        let path = platform_config_file().expect("HOME is set in a test run");
207        assert!(path.ends_with("temporalio/temporal.toml"), "{path:?}");
208        #[cfg(target_os = "macos")]
209        assert!(
210            path.to_string_lossy()
211                .contains("Library/Application Support"),
212            "{path:?}"
213        );
214    }
215
216    #[test]
217    fn the_xdg_path_is_only_a_fallback() {
218        // Consulted when the CLI's own file is absent, so a config someone put in
219        // ~/.config on a Mac is found rather than silently ignored.
220        let path = xdg_config_file().expect("HOME is set in a test run");
221        assert!(path.ends_with("temporalio/temporal.toml"), "{path:?}");
222    }
223
224    #[test]
225    fn config_file_in_use_always_names_something() {
226        // `--config-path` has to print a path even when no file exists yet, otherwise it
227        // cannot answer "where should I create it".
228        assert!(config_file_in_use(None).is_some());
229    }
230}