Skip to main content

faucet_common_spanner/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! Shared Google Cloud Spanner types for the faucet-stream ecosystem.
3//!
4//! This crate holds everything the `faucet-source-spanner` and
5//! `faucet-sink-spanner` connectors have in common:
6//!
7//! - [`SpannerCredentials`] — the `{ type, config }` credentials enum shared
8//!   by both connectors (mirrors `faucet-common-bigquery`).
9//! - [`SpannerConnection`] — the flattened connection block (`project_id` /
10//!   `instance` / `database` / `auth` / `max_sessions` / `emulator_host`)
11//!   plus [`SpannerConnection::connect`] / [`SpannerConnection::connect_admin`]
12//!   client builders.
13//! - [`types`] — `INFORMATION_SCHEMA` type-string parsing and the mapping to
14//!   JSON-Schema fragments.
15//! - [`decode`] — generic Spanner row → `serde_json::Value` decoding for
16//!   arbitrary queries (no compile-time schema).
17//! - [`encode`] — `serde_json::Value` → Spanner mutation value encoding keyed
18//!   by the destination column type.
19
20pub mod decode;
21pub mod encode;
22pub mod types;
23
24use faucet_core::FaucetError;
25use gcloud_gax::conn::Environment;
26use gcloud_spanner::admin::AdminClientConfig;
27use gcloud_spanner::admin::client::Client as AdminClient;
28use gcloud_spanner::client::google_cloud_auth::credentials::CredentialsFile;
29use gcloud_spanner::client::{Client, ClientConfig};
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use std::time::Duration;
33
34/// Sessions Spanner allows per gRPC channel (a hard server-side limit the
35/// client enforces at construction time).
36const SESSIONS_PER_CHANNEL: usize = 100;
37
38/// How long a session acquisition may wait for a pooled session before
39/// failing. The crate default (1 s) is too tight for bursty page writes
40/// against a small pool; 30 s matches the channel connect/request timeouts.
41const SESSION_GET_TIMEOUT: Duration = Duration::from_secs(30);
42
43/// Credentials used to authenticate against Cloud Spanner.
44///
45/// Serialized in the project-wide adjacently-tagged shape:
46///
47/// ```yaml
48/// auth:
49///   type: service_account_key_path
50///   config:
51///     path: /secrets/sa.json
52/// ```
53#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
54#[serde(tag = "type", content = "config", rename_all = "snake_case")]
55pub enum SpannerCredentials {
56    /// Load a service-account key from a JSON file on disk.
57    ServiceAccountKeyPath {
58        /// Filesystem path to the service-account key JSON.
59        #[schemars(extend("x-faucet-resource" = "path"))]
60        path: String,
61    },
62    /// Inline service-account key JSON (prefer `${vault:…}` / `${env:…}`
63    /// interpolation over literal keys in config files).
64    ServiceAccountKey {
65        /// The full service-account key JSON document.
66        json: String,
67    },
68    /// Application Default Credentials: `GOOGLE_APPLICATION_CREDENTIALS`,
69    /// `gcloud auth application-default login`, or the GCE/GKE metadata
70    /// server.
71    #[default]
72    ApplicationDefault,
73}
74
75impl std::fmt::Debug for SpannerCredentials {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::ServiceAccountKeyPath { path } => f
79                .debug_struct("ServiceAccountKeyPath")
80                .field("path", path)
81                .finish(),
82            // Never print inline key material.
83            Self::ServiceAccountKey { .. } => f
84                .debug_struct("ServiceAccountKey")
85                .field("json", &"***")
86                .finish(),
87            Self::ApplicationDefault => f.debug_struct("ApplicationDefault").finish(),
88        }
89    }
90}
91
92fn default_max_sessions() -> usize {
93    100
94}
95
96/// Connection block shared by the Spanner source and sink configs (flattened
97/// into each connector config via `#[serde(flatten)]`).
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct SpannerConnection {
100    /// GCP project ID that owns the Spanner instance.
101    pub project_id: String,
102    /// Spanner instance ID.
103    pub instance: String,
104    /// Database name within the instance.
105    pub database: String,
106    /// Credentials. Defaults to Application Default Credentials.
107    #[serde(default)]
108    pub auth: SpannerCredentials,
109    /// Upper bound on pooled Spanner sessions (server-side resources).
110    /// Channels are sized automatically at one per 100 sessions.
111    #[serde(default = "default_max_sessions")]
112    pub max_sessions: usize,
113    /// Emulator endpoint override (`host:port`, e.g. `localhost:9010`).
114    /// When set, the connection is plaintext and unauthenticated — exactly
115    /// what `SPANNER_EMULATOR_HOST` does, but scoped to this connector so
116    /// parallel pipelines/tests don't race on a process-global env var.
117    /// The env var is still honored when this field is unset.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    #[schemars(extend("x-faucet-resource" = "url"))]
120    pub emulator_host: Option<String>,
121}
122
123/// The config fields that make a Spanner client built from `config`
124/// authenticate with the identity of the host it runs on: `auth` when it
125/// selects `application_default` (also when absent) and no `emulator_host`
126/// is set.
127pub fn host_identity(config: &faucet_core::Value) -> Vec<String> {
128    use faucet_core::capabilities::config::{non_empty_str_at, tag_at};
129    let adc = tag_at(config, "/auth").unwrap_or("application_default") == "application_default";
130    if adc && !non_empty_str_at(config, "/emulator_host") {
131        vec!["auth".to_string()]
132    } else {
133        Vec::new()
134    }
135}
136
137impl SpannerConnection {
138    /// Refuse Application Default Credentials outside an emulator where the
139    /// task may not use the host's identity.
140    fn check_host_identity(&self) -> Result<(), FaucetError> {
141        let emulated = self.emulator_host.is_some()
142            || std::env::var_os("SPANNER_EMULATOR_HOST").is_some_and(|v| !v.is_empty());
143        if matches!(self.auth, SpannerCredentials::ApplicationDefault) && !emulated {
144            faucet_core::resolve::require_host_credentials("spanner", "auth")?;
145        }
146        Ok(())
147    }
148
149    /// Fully-qualified database path:
150    /// `projects/{project}/instances/{instance}/databases/{database}`.
151    pub fn database_path(&self) -> String {
152        format!(
153            "projects/{}/instances/{}/databases/{}",
154            self.project_id, self.instance, self.database
155        )
156    }
157
158    /// Validate the connection block at config-load time.
159    pub fn validate(&self) -> Result<(), FaucetError> {
160        for (field, value) in [
161            ("project_id", &self.project_id),
162            ("instance", &self.instance),
163            ("database", &self.database),
164        ] {
165            if value.trim().is_empty() {
166                return Err(FaucetError::Config(format!(
167                    "spanner: `{field}` must not be empty"
168                )));
169            }
170        }
171        if self.max_sessions == 0 {
172            return Err(FaucetError::Config(
173                "spanner: `max_sessions` must be at least 1".into(),
174            ));
175        }
176        Ok(())
177    }
178
179    /// Build the data-plane [`Client`]. Honors `emulator_host` (and, when
180    /// unset, the `SPANNER_EMULATOR_HOST` env var via the client default).
181    pub async fn connect(&self) -> Result<Client, FaucetError> {
182        self.validate()?;
183        self.check_host_identity()?;
184        let mut config = ClientConfig::default();
185        config.channel_config.num_channels = self.max_sessions.div_ceil(SESSIONS_PER_CHANNEL);
186        config.session_config.min_opened = 1;
187        config.session_config.max_idle = self.max_sessions;
188        config.session_config.max_opened = self.max_sessions;
189        config.session_config.session_get_timeout = SESSION_GET_TIMEOUT;
190        if let Some(host) = &self.emulator_host {
191            config.environment = Environment::Emulator(host.clone());
192        }
193        // Credential application is a no-op under an emulator environment,
194        // so it is safe to apply unconditionally.
195        let config = self.apply_data_credentials(config).await?;
196        Client::new(self.database_path(), config)
197            .await
198            .map_err(|e| FaucetError::Custom(Box::new(e)))
199    }
200
201    /// Build the admin (DDL) client. Same credential and emulator handling
202    /// as [`connect`](Self::connect).
203    pub async fn connect_admin(&self) -> Result<AdminClient, FaucetError> {
204        self.validate()?;
205        self.check_host_identity()?;
206        let mut config = AdminClientConfig::default();
207        if let Some(host) = &self.emulator_host {
208            config.environment = Environment::Emulator(host.clone());
209        }
210        let config = match &self.auth {
211            SpannerCredentials::ApplicationDefault => config
212                .with_auth()
213                .await
214                .map_err(|e| FaucetError::Auth(format!("spanner ADC: {e}")))?,
215            SpannerCredentials::ServiceAccountKeyPath { path } => config
216                .with_credentials(credentials_from_file(path).await?)
217                .await
218                .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}")))?,
219            SpannerCredentials::ServiceAccountKey { json } => config
220                .with_credentials(credentials_from_str(json).await?)
221                .await
222                .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}")))?,
223        };
224        AdminClient::new(config)
225            .await
226            .map_err(|e| FaucetError::Custom(Box::new(e)))
227    }
228
229    async fn apply_data_credentials(
230        &self,
231        config: ClientConfig,
232    ) -> Result<ClientConfig, FaucetError> {
233        match &self.auth {
234            SpannerCredentials::ApplicationDefault => config
235                .with_auth()
236                .await
237                .map_err(|e| FaucetError::Auth(format!("spanner ADC: {e}"))),
238            SpannerCredentials::ServiceAccountKeyPath { path } => config
239                .with_credentials(credentials_from_file(path).await?)
240                .await
241                .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}"))),
242            SpannerCredentials::ServiceAccountKey { json } => config
243                .with_credentials(credentials_from_str(json).await?)
244                .await
245                .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}"))),
246        }
247    }
248}
249
250async fn credentials_from_file(path: &str) -> Result<CredentialsFile, FaucetError> {
251    faucet_core::resolve::check_google_credentials_file(
252        "spanner",
253        "auth",
254        std::path::Path::new(path),
255    )?;
256    CredentialsFile::new_from_file(path.to_string())
257        .await
258        .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}")))
259}
260
261async fn credentials_from_str(json: &str) -> Result<CredentialsFile, FaucetError> {
262    faucet_core::resolve::check_google_credentials("spanner", "auth", json)?;
263    CredentialsFile::new_from_str(json)
264        .await
265        .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}")))
266}
267
268/// Quote a Spanner identifier with backticks. GoogleSQL quoted identifiers
269/// take string-literal escapes, so an embedded backslash or backtick is
270/// backslash-escaped (a doubled backtick would end the identifier).
271pub fn quote_ident_spanner(ident: &str) -> String {
272    format!("`{}`", ident.replace('\\', "\\\\").replace('`', "\\`"))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use serde_json::json;
279
280    #[test]
281    fn host_identity_follows_the_selected_credentials() {
282        assert_eq!(host_identity(&json!({})), vec!["auth"]);
283        assert!(host_identity(&json!({"emulator_host": "localhost:9010"})).is_empty());
284        for t in ["service_account_key_path", "service_account_key"] {
285            assert!(
286                host_identity(&json!({"auth": {"type": t}})).is_empty(),
287                "{t}"
288            );
289        }
290    }
291
292    #[tokio::test]
293    async fn caller_runs_take_only_service_account_keys() {
294        if std::env::var_os("SPANNER_EMULATOR_HOST").is_some() {
295            return;
296        }
297        let conn: SpannerConnection = serde_json::from_value(json!({
298            "project_id": "p", "instance": "i", "database": "d",
299            "auth": {"type": "service_account_key", "config": {
300                "json": "{\"type\": \"external_account\", \"token_url\": \"http://127.0.0.1:1/t\"}"
301            }}
302        }))
303        .unwrap();
304        let caller = std::sync::Arc::new(
305            faucet_core::resolve::ReferencePolicy::deny_all()
306                .with_path_roots(["/srv/data"])
307                .any_url_scheme()
308                .any_url_host(),
309        );
310        faucet_core::http::with_reference_policy(caller, async {
311            for err in [
312                conn.connect().await.err().expect("refused").to_string(),
313                conn.connect_admin()
314                    .await
315                    .err()
316                    .expect("refused")
317                    .to_string(),
318            ] {
319                assert!(err.contains("spanner: `auth`"), "{err}");
320                assert!(err.contains("external_account"), "{err}");
321            }
322        })
323        .await;
324    }
325
326    #[tokio::test]
327    async fn ambient_clients_are_refused_inside_a_caller_run() {
328        if std::env::var_os("SPANNER_EMULATOR_HOST").is_some() {
329            return;
330        }
331        let conn: SpannerConnection = serde_json::from_value(json!({
332            "project_id": "p", "instance": "i", "database": "d"
333        }))
334        .unwrap();
335        faucet_core::resolve::with_host_credentials(false, async {
336            let err = conn.connect().await.err().expect("refused").to_string();
337            assert!(err.contains("spanner: `auth`"), "{err}");
338            let err = conn
339                .connect_admin()
340                .await
341                .err()
342                .expect("refused")
343                .to_string();
344            assert!(err.contains("spanner: `auth`"), "{err}");
345            let emulated = SpannerConnection {
346                emulator_host: Some("127.0.0.1:1".into()),
347                ..conn.clone()
348            };
349            assert!(emulated.check_host_identity().is_ok());
350        })
351        .await;
352    }
353
354    #[test]
355    fn credentials_serde_shape_is_adjacently_tagged() {
356        let c: SpannerCredentials = serde_json::from_value(json!({
357            "type": "service_account_key_path",
358            "config": {"path": "/tmp/sa.json"}
359        }))
360        .unwrap();
361        assert!(matches!(
362            c,
363            SpannerCredentials::ServiceAccountKeyPath { ref path } if path == "/tmp/sa.json"
364        ));
365        let adc: SpannerCredentials =
366            serde_json::from_value(json!({"type": "application_default"})).unwrap();
367        assert!(matches!(adc, SpannerCredentials::ApplicationDefault));
368        assert!(matches!(
369            SpannerCredentials::default(),
370            SpannerCredentials::ApplicationDefault
371        ));
372    }
373
374    #[test]
375    fn debug_masks_inline_key_material() {
376        let c = SpannerCredentials::ServiceAccountKey {
377            json: "{\"private_key\": \"SECRET\"}".into(),
378        };
379        let rendered = format!("{c:?}");
380        assert!(!rendered.contains("SECRET"));
381        assert!(rendered.contains("***"));
382    }
383
384    fn conn() -> SpannerConnection {
385        SpannerConnection {
386            project_id: "p".into(),
387            instance: "i".into(),
388            database: "d".into(),
389            auth: SpannerCredentials::default(),
390            max_sessions: default_max_sessions(),
391            emulator_host: None,
392        }
393    }
394
395    #[test]
396    fn database_path_shape() {
397        assert_eq!(conn().database_path(), "projects/p/instances/i/databases/d");
398    }
399
400    #[test]
401    fn validate_rejects_empty_fields_and_zero_sessions() {
402        let mut c = conn();
403        c.project_id = " ".into();
404        assert!(matches!(c.validate(), Err(FaucetError::Config(_))));
405        let mut c = conn();
406        c.instance = String::new();
407        assert!(c.validate().is_err());
408        let mut c = conn();
409        c.database = String::new();
410        assert!(c.validate().is_err());
411        let mut c = conn();
412        c.max_sessions = 0;
413        assert!(c.validate().is_err());
414        assert!(conn().validate().is_ok());
415    }
416
417    #[test]
418    fn connection_defaults_from_minimal_config() {
419        let c: SpannerConnection = serde_json::from_value(json!({
420            "project_id": "p", "instance": "i", "database": "d"
421        }))
422        .unwrap();
423        assert_eq!(c.max_sessions, 100);
424        assert!(c.emulator_host.is_none());
425        assert!(matches!(c.auth, SpannerCredentials::ApplicationDefault));
426    }
427
428    #[test]
429    fn quote_ident_backslash_escapes() {
430        assert_eq!(quote_ident_spanner("plain"), "`plain`");
431        assert_eq!(quote_ident_spanner("we`ird"), "`we\\`ird`");
432        assert_eq!(quote_ident_spanner("a\\b"), "`a\\\\b`");
433    }
434
435    #[tokio::test]
436    async fn credentials_from_missing_file_is_a_typed_auth_error() {
437        let Err(err) = credentials_from_file("/definitely/not/here.json").await else {
438            panic!("missing key file unexpectedly loaded");
439        };
440        assert!(matches!(err, FaucetError::Auth(_)));
441        assert!(err.to_string().contains("/definitely/not/here.json"));
442    }
443
444    #[tokio::test]
445    async fn credentials_from_invalid_json_is_a_typed_auth_error() {
446        let Err(err) = credentials_from_str("{not json").await else {
447            panic!("invalid key json unexpectedly parsed");
448        };
449        assert!(matches!(err, FaucetError::Auth(_)));
450        assert!(err.to_string().contains("inline key"));
451    }
452
453    /// A syntactically valid (but fake) service-account document — every
454    /// field except `type` is optional in the credentials-file schema.
455    const FAKE_SA: &str = r#"{"type": "service_account", "project_id": "p"}"#;
456
457    /// Every credential variant must build under an emulator environment
458    /// (where token providers are inert) and then fail fast at client
459    /// construction against a dead endpoint — proving the credential arms
460    /// never panic and map errors into `FaucetError`.
461    #[tokio::test]
462    async fn connect_exercises_every_credential_variant_against_dead_emulator() {
463        let key_path = std::env::temp_dir().join("faucet-spanner-test-sa.json");
464        std::fs::write(&key_path, FAKE_SA).expect("write fake key");
465        let variants = [
466            SpannerCredentials::ApplicationDefault,
467            SpannerCredentials::ServiceAccountKey {
468                json: FAKE_SA.to_string(),
469            },
470            SpannerCredentials::ServiceAccountKeyPath {
471                path: key_path.to_string_lossy().into_owned(),
472            },
473        ];
474        for auth in variants {
475            let mut c = conn();
476            // Port 1 is never a Spanner emulator: connection is refused
477            // immediately, after the credential arm has already run.
478            c.emulator_host = Some("127.0.0.1:1".into());
479            c.auth = auth.clone();
480            let Err(err) = c.connect().await else {
481                panic!("data-plane connect unexpectedly succeeded for {auth:?}");
482            };
483            assert!(
484                matches!(err, FaucetError::Custom(_)),
485                "data-plane connect: unexpected error {err:?} for {auth:?}"
486            );
487            let Err(err) = c.connect_admin().await else {
488                panic!("admin connect unexpectedly succeeded for {auth:?}");
489            };
490            assert!(
491                matches!(err, FaucetError::Custom(_)),
492                "admin connect: unexpected error {err:?} for {auth:?}"
493            );
494        }
495    }
496}