pywatt_sdk 0.5.3

Standardized SDK for building PyWatt modules in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
use crate::ipc_types::{
    ClientRequest, GetSecretRequest, RotatedNotification, RotationAckRequest as RotationAck,
    ServerResponse,
};
use crate::secret_client::error::SecretClientError;
use dashmap::DashMap;
use once_cell::sync::Lazy;
use secrecy::SecretString;
use std::io;
use std::io::{BufRead, BufReader, Write};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::time::{Duration, timeout};
use tracing::{debug, error, info};
use serde::{Deserialize, Serialize};

// Global singleton for the default client
// This might need rethinking in the SDK context. Maybe it shouldn't be global?
// Or maybe it's initialized differently.
static DEFAULT_CLIENT: Lazy<Arc<SecretClient>> = Lazy::new(|| {
    // Use BufReader to wrap stdin since it doesn't implement BufRead
    let stdin = BufReader::new(io::stdin());
    Arc::new(SecretClient::new_with_io(stdin, io::stdout()))
});

/// Request handling mode
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestMode {
    /// Use cache first, fallback to remote if not found
    CacheThenRemote,
    /// Force request to remote, update cache
    ForceRemote,
    /// Use cache only, fail if not found
    CacheOnly,
}

/// A client for communicating with the orchestrator's secret provider
// Make SecretClient fields private or pub(crate) unless they need to be public API
pub struct SecretClient {
    cache: DashMap<String, SecretString>,
    rotation_tracking: DashMap<String, String>,
    last_ack: Mutex<Option<String>>,
    rotation_tx: broadcast::Sender<Vec<String>>,
    input: Mutex<Box<dyn BufRead + Send>>,
    output: Mutex<Box<dyn Write + Send>>,
}

// Implement Debug manually to avoid exposing sensitive information
impl std::fmt::Debug for SecretClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SecretClient")
            .field("cache_size", &self.cache.len())
            .field("rotation_tracking_size", &self.rotation_tracking.len())
            .field(
                "has_last_ack",
                &self
                    .last_ack
                    .try_lock()
                    .map(|guard| guard.is_some())
                    .unwrap_or(false),
            )
            .field("broadcast_receivers", &self.rotation_tx.receiver_count())
            .finish_non_exhaustive()
    }
}

impl SecretClient {
    /// Creates a new SecretClient with the given I/O channels
    pub fn new_with_io<R, W>(input: R, output: W) -> Self
    where
        R: BufRead + Send + 'static,
        W: Write + Send + 'static,
    {
        let (tx, _) = broadcast::channel(10);
        Self {
            cache: DashMap::new(),
            rotation_tracking: DashMap::new(),
            last_ack: Mutex::new(None),
            rotation_tx: tx,
            input: Mutex::new(Box::new(input)),
            output: Mutex::new(Box::new(output)),
        }
    }

    /// Gets the default client instance (consider if this is still needed)
    pub fn global() -> Arc<Self> {
        DEFAULT_CLIENT.clone()
    }

    /// Gets a secret value, with caching based on mode
    pub async fn get_secret(
        &self,
        key: &str,
        mode: RequestMode,
    ) -> Result<SecretString, SecretClientError> {
        // Check cache first if not forcing remote
        if mode != RequestMode::ForceRemote {
            if let Some(cached) = self.cache.get(key) {
                debug!("Found secret '{}' in cache", key);
                return Ok(cached.clone());
            } else if mode == RequestMode::CacheOnly {
                return Err(SecretClientError::NotFound(key.to_string()));
            }
        }

        // Not in cache or forcing remote - request from orchestrator
        debug!("Requesting secret '{}' from orchestrator", key);
        let request = ClientRequest::GetSecret(GetSecretRequest {
            name: key.to_string(),
        });

        let response = self.send_request(&request).await?;

        match response {
            ServerResponse::Secret(secret_resp) => {
                if secret_resp.name != key {
                    return Err(SecretClientError::Unexpected(format!(
                        "Requested key '{}' but got '{}'",
                        key, secret_resp.name
                    )));
                }
                // Wrap the returned String into a SecretString
                let secret_value = SecretString::new(secret_resp.value.clone().into());
                // Update cache and rotation tracking
                if let Some(rotation_id) = &secret_resp.rotation_id {
                    self.rotation_tracking
                        .insert(secret_resp.name.clone(), rotation_id.clone());
                }
                self.cache.insert(key.to_string(), secret_value.clone());
                Ok(secret_value)
            }
            other => Err(SecretClientError::Unexpected(format!(
                "Expected Secret response, got {:?}",
                other
            ))),
        }
    }

    /// Sends a request to the orchestrator and waits for response
    async fn send_request(
        &self,
        request: &ClientRequest,
    ) -> Result<ServerResponse, SecretClientError> {
        // Serialize request
        let json = serde_json::to_string(request)?;

        // Write request to orchestrator via stdout
        {
            let mut output = self.output.lock().await;
            writeln!(*output, "{}", json)?;
            output.flush()?;
        }

        // Await response with timeout
        let result = timeout(Duration::from_secs(5), async {
            let mut input = self.input.lock().await;
            let mut line = String::new();
            input.read_line(&mut line)?;
            let resp: ServerResponse = serde_json::from_str(line.trim())?;
            Ok(resp)
        })
        .await;

        match result {
            Ok(Ok(resp)) => Ok(resp),
            Ok(Err(e)) => Err(e),
            Err(_) => Err(SecretClientError::Other(
                "timeout waiting for response".into(),
            )),
        }
    }

    /// Acknowledges a rotation with success or failure
    pub async fn acknowledge_rotation(
        &self,
        rotation_id: &str,
        status: &str,
    ) -> Result<(), SecretClientError> {
        let request = ClientRequest::RotationAck(RotationAck {
            rotation_id: rotation_id.to_string(),
            status: status.to_string(),
            message: None,
        });

        let _ = self.send_request(&request).await?;

        // Store acknowledgment
        let mut last_ack = self.last_ack.lock().await;
        *last_ack = Some(rotation_id.to_string());

        Ok(())
    }

    /// Subscribes to rotation events
    pub fn subscribe_to_rotations(&self) -> broadcast::Receiver<Vec<String>> {
        self.rotation_tx.subscribe()
    }

    /// Handles an incoming rotation notification
    pub async fn handle_rotation_notification(
        &self,
        notification: RotatedNotification,
    ) -> Result<(), SecretClientError> {
        info!(
            "Received rotation notification for {} keys with ID {}",
            notification.keys.len(),
            notification.rotation_id
        );

        // Invalidate cache for the rotated keys
        for key in &notification.keys {
            self.cache.remove(key);
        }

        // Notify subscribers
        let _ = self.rotation_tx.send(notification.keys.clone());

        // Auto-acknowledge the rotation
        self.acknowledge_rotation(&notification.rotation_id, "success")
            .await?;

        Ok(())
    }

    /// Process an incoming server message
    pub async fn process_server_message(&self, message: &str) -> Result<(), SecretClientError> {
        let response: ServerResponse = serde_json::from_str(message)?;

        match response {
            ServerResponse::Init(_) => {
                // Ignore initialization messages handled elsewhere (e.g., handshake module)
            }
            ServerResponse::Rotated(notification) => {
                self.handle_rotation_notification(notification).await?;
            }
            ServerResponse::Secret(secret_resp) => {
                // Wrap the returned String into a SecretString and store in cache
                let secret_value = SecretString::new(secret_resp.value.clone().into());
                self.cache
                    .insert(secret_resp.name.clone(), secret_value.clone());

                // Track rotation ID if present
                if let Some(rotation_id) = &secret_resp.rotation_id {
                    self.rotation_tracking
                        .insert(secret_resp.name.clone(), rotation_id.clone());
                }
            }
            ServerResponse::Shutdown => {
                // Handle shutdown signal if needed by the client itself
                info!("Received shutdown signal from orchestrator");
            }
            // Note: The catch-all pattern `_` below is technically unreachable because
            // all variants of ServerResponse are explicitly handled above. Leaving it
            // for future-proofing in case new variants are added to ipc_types,
            // although a compiler warning will occur.
            #[allow(unreachable_patterns)]
            _ => {
                debug!(
                    "Received unexpected message type from orchestrator: {:?}",
                    response
                );
            }
        }

        Ok(())
    }

    /// Starts a background task that continuously processes incoming messages on the input channel.
    /// Returns a [`tokio::task::JoinHandle`] for the spawned task.
    pub fn start_background_task(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
        let client = Arc::clone(self);
        tokio::spawn(async move {
            let mut buffer = String::new();
            loop {
                buffer.clear();
                // Read a line from input (blocking)
                let bytes_read = {
                    let mut input = client.input.lock().await;
                    match input.read_line(&mut buffer) {
                        Ok(n) => n,
                        Err(e) => {
                            error!("Error reading from orchestrator: {}", e);
                            break; // Exit task on read error
                        }
                    }
                };

                if bytes_read == 0 {
                    // EOF or error – break loop
                    info!("Orchestrator input stream closed.");
                    break;
                }

                if let Err(e) = client.process_server_message(buffer.trim()).await {
                    error!("Error processing message from orchestrator: {}", e);
                    // Decide whether to continue or break on processing error
                }
            }
        })
    }

    /// Establishes a connection to the orchestrator based on the given API URL and module ID.
    /// This is likely now handled by the main SDK `get_module_secret_client` function.
    pub async fn connect(api_url: &str, module_id: &str) -> Result<Arc<Self>, SecretClientError> {
        // In the merged SDK, this is a simpler implementation that creates a client using standard IO
        let _ = (api_url, module_id); // For future compatibility with API params
        let reader = BufReader::new(io::stdin());
        let client = Arc::new(SecretClient::new_with_io(reader, io::stdout()));
        client.start_background_task(); // Start watching for messages
        Ok(client)
    }

    /// Convenience alias for `connect`, returning a shared `Arc`.
    pub async fn new(api_url: &str, module_id: &str) -> Result<Arc<Self>, SecretClientError> {
        SecretClient::connect(api_url, module_id).await
    }

    /// Convenience helper matching older API: fetches secret with default mode
    pub async fn get(&self, key: &str) -> Result<SecretString, SecretClientError> {
        self.get_secret(key, RequestMode::CacheThenRemote).await
    }

    /// Creates a dummy in-memory client for tests or smoke runs.
    pub fn new_dummy() -> Self {
        // Use empty reader/writer so no IPC is attempted
        let reader = BufReader::new(io::empty());
        let writer = io::sink();
        SecretClient::new_with_io(reader, writer)
    }

    #[cfg(test)]
    /// Creates a SecretClient suitable for testing
    pub fn for_test() -> Self {
        use tokio::sync::broadcast;

        // Use empty reader/writer so no IPC is attempted
        let reader = std::io::BufReader::new(std::io::empty());
        let writer = std::io::sink();
        let (tx, _) = broadcast::channel(10);

        Self {
            cache: dashmap::DashMap::new(),
            rotation_tracking: dashmap::DashMap::new(),
            last_ack: tokio::sync::Mutex::new(None),
            rotation_tx: tx,
            input: tokio::sync::Mutex::new(Box::new(reader)),
            output: tokio::sync::Mutex::new(Box::new(writer)),
        }
    }

    #[cfg(test)]
    /// Testing helper to insert a secret directly into the cache.
    pub async fn insert_test_secret(&self, key: &str, value: &str) {
        self.cache
            .insert(key.to_string(), SecretString::new(value.to_string().into()));
    }

    #[cfg(test)]
    /// Testing helper to send a rotation notification for given keys.
    pub fn send_test_rotation(
        &self,
        keys: Vec<String>,
    ) -> Result<usize, broadcast::error::SendError<Vec<String>>> {
        self.rotation_tx.send(keys)
    }

    /// Retrieves and parses a secret value into a strongly-typed `Secret<T>`.
    ///
    /// This method uses `get_typed_secret` from the `typed_secret` module and provides
    /// a convenient way to get typed secrets directly from the `SecretClient`.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use pywatt_sdk::secret_client::SecretClient;
    /// use std::sync::Arc;
    ///
    /// #[tokio::main]
    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     let client = Arc::new(SecretClient::new("http://localhost:9000", "my-module").await?);
    ///
    ///     // Get a port number as u16
    ///     let port = client.get_typed::<u16>("PORT").await?;
    ///     println!("Using port: {}", port.expose_secret());
    ///
    ///     Ok(())
    /// }
    /// ```
    pub async fn get_typed<T>(
        &self,
        key: &str,
    ) -> Result<crate::security::secrets::typed_secret::Secret<T>, crate::security::secrets::typed_secret::TypedSecretError>
    where
        T: std::str::FromStr,
        T::Err: std::fmt::Display,
    {
        crate::security::secrets::typed_secret::get_typed_secret(self, key).await
    }

    /// Retrieves a secret as a string value.
    ///
    /// This is a convenience wrapper around `get_typed<String>`.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use pywatt_sdk::secret_client::SecretClient;
    /// use std::sync::Arc;
    ///
    /// #[tokio::main]
    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     let client = Arc::new(SecretClient::new("http://localhost:9000", "my-module").await?);
    ///
    ///     // Get an API key as a string
    ///     let api_key = client.get_string("API_KEY").await?;
    ///
    ///     // Use the API key
    ///     let auth_header = format!("Authorization: Bearer {}", api_key.expose_secret());
    ///
    ///     Ok(())
    /// }
    /// ```
    pub async fn get_string(
        &self,
        key: &str,
    ) -> Result<crate::security::secrets::typed_secret::Secret<String>, crate::security::secrets::typed_secret::TypedSecretError> {
        crate::security::secrets::typed_secret::get_string_secret(self, key).await
    }

    /// Retrieves a secret as a boolean value.
    ///
    /// This is a convenience wrapper around `get_typed<bool>`.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use pywatt_sdk::secret_client::SecretClient;
    /// use std::sync::Arc;
    ///
    /// #[tokio::main]
    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     let client = Arc::new(SecretClient::new("http://localhost:9000", "my-module").await?);
    ///
    ///     // Get a feature flag as a boolean
    ///     let feature_enabled = client.get_bool("FEATURE_ENABLED").await?;
    ///
    ///     if *feature_enabled.expose_secret() {
    ///         println!("Feature is enabled!");
    ///     }
    ///
    ///     Ok(())
    /// }
    /// ```
    pub async fn get_bool(
        &self,
        key: &str,
    ) -> Result<crate::security::secrets::typed_secret::Secret<bool>, crate::security::secrets::typed_secret::TypedSecretError> {
        crate::security::secrets::typed_secret::get_bool_secret(self, key).await
    }

    /// Retrieves a secret as an integer value of type T.
    ///
    /// This is a convenience wrapper around `get_typed<T>` for numeric types.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use pywatt_sdk::secret_client::SecretClient;
    /// use std::sync::Arc;
    ///
    /// #[tokio::main]
    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     let client = Arc::new(SecretClient::new("http://localhost:9000", "my-module").await?);
    ///
    ///     // Get a timeout value as u64 seconds
    ///     let timeout_secs = client.get_int::<u64>("TIMEOUT_SECONDS").await?;
    ///
    ///     println!("Using timeout: {} seconds", timeout_secs.expose_secret());
    ///
    ///     Ok(())
    /// }
    /// ```
    pub async fn get_int<T>(
        &self,
        key: &str,
    ) -> Result<crate::security::secrets::typed_secret::Secret<T>, crate::security::secrets::typed_secret::TypedSecretError>
    where
        T: std::str::FromStr + std::fmt::Debug,
        T::Err: std::fmt::Display,
    {
        crate::security::secrets::typed_secret::get_int_secret(self, key).await
    }
}

/// Convenience function to get a secret from the default client (if global client is kept)
pub async fn get_secret(key: &str) -> Result<SecretString, SecretClientError> {
    SecretClient::global()
        .get_secret(key, RequestMode::CacheThenRemote)
        .await
}

/// Safe way to print a string to stdout as JSON (if needed outside of IPC)
pub fn print_json<T: serde::Serialize>(value: &T) -> Result<(), SecretClientError> {
    let json = serde_json::to_string(value)?;
    // Use std::println! directly, assuming stdout isn't exclusively for IPC
    println!("{}", json);
    Ok(())
}

/// Safe way to print a message to stderr
pub fn print_stderr(msg: &str) -> Result<(), SecretClientError> {
    eprintln!("{}", msg);
    Ok(())
}