Skip to main content

ralph_workflow/config/
cloud.rs

1//! Cloud and git remote configuration types.
2//!
3//! This module defines the cloud runtime configuration and git remote types
4//! used when Ralph is running in cloud-hosted mode.
5
6/// Cloud runtime configuration (internal).
7///
8/// This struct is loaded from environment variables when cloud mode is enabled.
9#[derive(Debug, Clone, Default)]
10pub struct CloudConfig {
11    /// Enable cloud reporting mode (internal env-config).
12    pub enabled: bool,
13    /// Cloud API base URL.
14    pub api_url: Option<String>,
15    /// Bearer token for API authentication.
16    pub api_token: Option<String>,
17    /// Run ID assigned by cloud orchestrator.
18    pub run_id: Option<String>,
19    /// Heartbeat interval in seconds.
20    pub heartbeat_interval_secs: u32,
21    /// Whether to continue on API failures.
22    pub graceful_degradation: bool,
23    /// Git remote configuration
24    pub git_remote: GitRemoteConfig,
25}
26
27/// Git remote configuration (internal).
28///
29/// Loaded from environment variables when cloud mode is enabled.
30#[derive(Debug, Clone)]
31pub struct GitRemoteConfig {
32    /// Authentication method for git operations
33    pub auth_method: GitAuthMethod,
34    /// Branch to push to (defaults to current branch)
35    pub push_branch: Option<String>,
36    /// Whether to create a PR instead of direct push
37    pub create_pr: bool,
38    /// PR title template (supports {`run_id`}, {`prompt_summary`} placeholders)
39    pub pr_title_template: Option<String>,
40    /// PR body template
41    pub pr_body_template: Option<String>,
42    /// Base branch for PR (defaults to main/master)
43    pub pr_base_branch: Option<String>,
44    /// Whether to force push (dangerous, disabled by default)
45    pub force_push: bool,
46    /// Remote name (defaults to "origin")
47    pub remote_name: String,
48}
49
50#[derive(Debug, Clone)]
51pub enum GitAuthMethod {
52    /// Use SSH key (default for containers with mounted keys)
53    SshKey {
54        /// Path to private key (default: /`root/.ssh/id_rsa` or `SSH_AUTH_SOCK`)
55        key_path: Option<String>,
56    },
57    /// Use token-based HTTPS authentication
58    Token {
59        /// Git token (from `RALPH_GIT_TOKEN` env var)
60        token: String,
61        /// Username for token auth (often "oauth2" or "x-access-token")
62        username: String,
63    },
64    /// Use git credential helper (for cloud provider integrations)
65    CredentialHelper {
66        /// Helper command (e.g., "gcloud", "aws codecommit credential-helper")
67        helper: String,
68    },
69}
70
71/// Cloud configuration that is safe to store in reducer state / checkpoints.
72///
73/// This is a *redacted* view of [`CloudConfig`]: it carries only non-sensitive
74/// fields required for pure orchestration.
75///
76/// In particular, it MUST NOT contain API tokens, git tokens, or any other
77/// credential material.
78#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
79pub struct CloudStateConfig {
80    pub enabled: bool,
81    pub api_url: Option<String>,
82    pub run_id: Option<String>,
83    pub heartbeat_interval_secs: u32,
84    pub graceful_degradation: bool,
85    pub git_remote: GitRemoteStateConfig,
86}
87
88#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
89pub struct GitRemoteStateConfig {
90    pub auth_method: GitAuthStateMethod,
91    pub push_branch: String,
92    pub create_pr: bool,
93    pub pr_title_template: Option<String>,
94    pub pr_body_template: Option<String>,
95    pub pr_base_branch: Option<String>,
96    pub force_push: bool,
97    pub remote_name: String,
98}
99
100impl Default for GitRemoteStateConfig {
101    fn default() -> Self {
102        Self {
103            auth_method: GitAuthStateMethod::SshKey { key_path: None },
104            push_branch: String::new(),
105            create_pr: false,
106            pr_title_template: None,
107            pr_body_template: None,
108            pr_base_branch: None,
109            force_push: false,
110            remote_name: "origin".to_string(),
111        }
112    }
113}
114
115#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
116pub enum GitAuthStateMethod {
117    SshKey { key_path: Option<String> },
118    Token { username: String },
119    CredentialHelper { helper: String },
120}
121
122impl Default for GitAuthStateMethod {
123    fn default() -> Self {
124        Self::SshKey { key_path: None }
125    }
126}
127
128impl CloudStateConfig {
129    #[must_use]
130    pub fn disabled() -> Self {
131        Self {
132            enabled: false,
133            api_url: None,
134            run_id: None,
135            heartbeat_interval_secs: 30,
136            graceful_degradation: true,
137            git_remote: GitRemoteStateConfig::default(),
138        }
139    }
140}
141
142impl From<&CloudConfig> for CloudStateConfig {
143    fn from(cfg: &CloudConfig) -> Self {
144        let auth_method = match &cfg.git_remote.auth_method {
145            GitAuthMethod::SshKey { key_path } => GitAuthStateMethod::SshKey {
146                key_path: key_path.clone(),
147            },
148            GitAuthMethod::Token { username, .. } => GitAuthStateMethod::Token {
149                username: username.clone(),
150            },
151            GitAuthMethod::CredentialHelper { helper } => GitAuthStateMethod::CredentialHelper {
152                helper: helper.clone(),
153            },
154        };
155
156        Self {
157            enabled: cfg.enabled,
158            api_url: cfg.api_url.clone(),
159            run_id: cfg.run_id.clone(),
160            heartbeat_interval_secs: cfg.heartbeat_interval_secs,
161            graceful_degradation: cfg.graceful_degradation,
162            git_remote: GitRemoteStateConfig {
163                auth_method,
164                push_branch: cfg.git_remote.push_branch.clone().unwrap_or_default(),
165                create_pr: cfg.git_remote.create_pr,
166                pr_title_template: cfg.git_remote.pr_title_template.clone(),
167                pr_body_template: cfg.git_remote.pr_body_template.clone(),
168                pr_base_branch: cfg.git_remote.pr_base_branch.clone(),
169                force_push: cfg.git_remote.force_push,
170                remote_name: cfg.git_remote.remote_name.clone(),
171            },
172        }
173    }
174}
175
176impl Default for GitAuthMethod {
177    fn default() -> Self {
178        Self::SshKey { key_path: None }
179    }
180}
181
182impl Default for GitRemoteConfig {
183    fn default() -> Self {
184        Self {
185            auth_method: GitAuthMethod::default(),
186            push_branch: None,
187            create_pr: false,
188            pr_title_template: None,
189            pr_body_template: None,
190            pr_base_branch: None,
191            force_push: false,
192            remote_name: "origin".to_string(),
193        }
194    }
195}
196
197impl CloudConfig {
198    /// Load cloud configuration from a caller-supplied env-var accessor.
199    /// This is the canonical implementation; `from_env` is a thin wrapper.
200    #[must_use]
201    pub fn from_env_fn(get: impl Fn(&str) -> Option<String>) -> Self {
202        let enabled =
203            get("RALPH_CLOUD_MODE").is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
204
205        if !enabled {
206            return Self::disabled();
207        }
208
209        Self {
210            enabled: true,
211            api_url: get("RALPH_CLOUD_API_URL"),
212            api_token: get("RALPH_CLOUD_API_TOKEN"),
213            run_id: get("RALPH_CLOUD_RUN_ID"),
214            heartbeat_interval_secs: get("RALPH_CLOUD_HEARTBEAT_INTERVAL")
215                .and_then(|v| v.parse().ok())
216                .unwrap_or(30),
217            graceful_degradation: get("RALPH_CLOUD_GRACEFUL_DEGRADATION")
218                .is_none_or(|v| !v.eq_ignore_ascii_case("false") && v != "0"),
219            git_remote: GitRemoteConfig::from_env_fn(|k| get(k)),
220        }
221    }
222
223    /// Load cloud config from environment variables ONLY.
224    /// Returns disabled config when cloud mode is not enabled.
225    #[must_use]
226    pub fn from_env() -> Self {
227        Self::from_env_fn(|k| std::env::var(k).ok())
228    }
229
230    #[must_use]
231    pub fn disabled() -> Self {
232        Self {
233            enabled: false,
234            api_url: None,
235            api_token: None,
236            run_id: None,
237            heartbeat_interval_secs: 30,
238            graceful_degradation: true,
239            git_remote: GitRemoteConfig::default(),
240        }
241    }
242
243    /// Validate that required fields are present when enabled.
244    ///
245    /// # Errors
246    ///
247    /// Returns error if the operation fails.
248    pub fn validate(&self) -> Result<(), String> {
249        if !self.enabled {
250            return Ok(());
251        }
252
253        let Some(api_url) = self.api_url.as_deref() else {
254            return Err("RALPH_CLOUD_API_URL must be set when cloud mode is enabled".to_string());
255        };
256        if !api_url
257            .trim_start()
258            .to_ascii_lowercase()
259            .starts_with("https://")
260        {
261            return Err(
262                "RALPH_CLOUD_API_URL must use https:// when cloud mode is enabled".to_string(),
263            );
264        }
265
266        if self.api_token.as_deref().unwrap_or_default().is_empty() {
267            return Err("RALPH_CLOUD_API_TOKEN must be set when cloud mode is enabled".to_string());
268        }
269
270        if self.run_id.as_deref().unwrap_or_default().is_empty() {
271            return Err("RALPH_CLOUD_RUN_ID must be set when cloud mode is enabled".to_string());
272        }
273
274        // Validate git remote config when cloud mode is enabled.
275        self.git_remote.validate()?;
276
277        Ok(())
278    }
279}
280
281impl GitRemoteConfig {
282    /// # Errors
283    ///
284    /// Returns an error if:
285    /// - Remote name is empty
286    /// - Push branch is invalid
287    /// - Auth method configuration is invalid
288    pub fn validate(&self) -> Result<(), String> {
289        if self.remote_name.trim().is_empty() {
290            return Err("RALPH_GIT_REMOTE must not be empty".to_string());
291        }
292
293        if let Some(branch) = self.push_branch.as_deref() {
294            let trimmed = branch.trim();
295            if trimmed.is_empty() {
296                return Err("RALPH_GIT_PUSH_BRANCH must not be empty when set".to_string());
297            }
298            if trimmed == "HEAD" {
299                return Err(
300                    "RALPH_GIT_PUSH_BRANCH must be a branch name (not literal 'HEAD')".to_string(),
301                );
302            }
303        }
304
305        match &self.auth_method {
306            GitAuthMethod::SshKey { key_path } => {
307                if let Some(path) = key_path.as_deref() {
308                    if path.trim().is_empty() {
309                        return Err("RALPH_GIT_SSH_KEY_PATH must not be empty when set".to_string());
310                    }
311                }
312            }
313            GitAuthMethod::Token { token, username } => {
314                if token.trim().is_empty() {
315                    return Err(
316                        "RALPH_GIT_TOKEN must be set when RALPH_GIT_AUTH_METHOD=token".to_string(),
317                    );
318                }
319                if username.trim().is_empty() {
320                    return Err(
321                        "RALPH_GIT_TOKEN_USERNAME must not be empty when RALPH_GIT_AUTH_METHOD=token"
322                            .to_string(),
323                    );
324                }
325            }
326            GitAuthMethod::CredentialHelper { helper } => {
327                if helper.trim().is_empty() {
328                    return Err(
329                        "RALPH_GIT_CREDENTIAL_HELPER must be set when RALPH_GIT_AUTH_METHOD=credential-helper"
330                            .to_string(),
331                    );
332                }
333            }
334        }
335
336        Ok(())
337    }
338
339    /// Load git-remote configuration from a caller-supplied env-var accessor.
340    /// This is the canonical implementation; `from_env` is a thin wrapper.
341    #[must_use]
342    pub fn from_env_fn(get: impl Fn(&str) -> Option<String>) -> Self {
343        let auth_method = match get("RALPH_GIT_AUTH_METHOD")
344            .unwrap_or_else(|| "ssh".to_string())
345            .to_lowercase()
346            .as_str()
347        {
348            "token" => {
349                let token = get("RALPH_GIT_TOKEN").unwrap_or_default();
350                let username =
351                    get("RALPH_GIT_TOKEN_USERNAME").unwrap_or_else(|| "x-access-token".to_string());
352                GitAuthMethod::Token { token, username }
353            }
354            "credential-helper" => {
355                let helper =
356                    get("RALPH_GIT_CREDENTIAL_HELPER").unwrap_or_else(|| "gcloud".to_string());
357                GitAuthMethod::CredentialHelper { helper }
358            }
359            _ => {
360                let key_path = get("RALPH_GIT_SSH_KEY_PATH");
361                GitAuthMethod::SshKey { key_path }
362            }
363        };
364
365        Self {
366            auth_method,
367            push_branch: get("RALPH_GIT_PUSH_BRANCH"),
368            create_pr: get("RALPH_GIT_CREATE_PR")
369                .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"),
370            pr_title_template: get("RALPH_GIT_PR_TITLE"),
371            pr_body_template: get("RALPH_GIT_PR_BODY"),
372            pr_base_branch: get("RALPH_GIT_PR_BASE_BRANCH"),
373            force_push: get("RALPH_GIT_FORCE_PUSH")
374                .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"),
375            remote_name: get("RALPH_GIT_REMOTE").unwrap_or_else(|| "origin".to_string()),
376        }
377    }
378
379    /// Load git-remote configuration from the process environment.
380    #[must_use]
381    pub fn from_env() -> Self {
382        Self::from_env_fn(|k| std::env::var(k).ok())
383    }
384}
385
386#[cfg(test)]
387mod cloud_tests {
388    use super::*;
389
390    #[test]
391    fn test_cloud_disabled_by_default() {
392        let config = CloudConfig::from_env_fn(|_| None);
393        assert!(!config.enabled);
394    }
395
396    #[test]
397    fn test_cloud_enabled_with_env_var() {
398        let env = [
399            ("RALPH_CLOUD_MODE", "true"),
400            ("RALPH_CLOUD_API_URL", "https://api.example.com"),
401            ("RALPH_CLOUD_API_TOKEN", "secret"),
402            ("RALPH_CLOUD_RUN_ID", "run123"),
403        ];
404        let config = CloudConfig::from_env_fn(|k| {
405            env.iter()
406                .find(|(key, _)| *key == k)
407                .map(|(_, v)| (*v).to_string())
408        });
409        assert!(config.enabled);
410        assert_eq!(config.api_url, Some("https://api.example.com".to_string()));
411        assert_eq!(config.run_id, Some("run123".to_string()));
412    }
413
414    #[test]
415    fn test_cloud_validation_requires_fields() {
416        let config = CloudConfig {
417            enabled: true,
418            api_url: None,
419            api_token: None,
420            run_id: None,
421            heartbeat_interval_secs: 30,
422            graceful_degradation: true,
423            git_remote: GitRemoteConfig::default(),
424        };
425
426        assert!(config.validate().is_err());
427    }
428
429    #[test]
430    fn test_git_auth_method_from_env() {
431        let env = [
432            ("RALPH_GIT_AUTH_METHOD", "token"),
433            ("RALPH_GIT_TOKEN", "ghp_test"),
434        ];
435        let config = GitRemoteConfig::from_env_fn(|k| {
436            env.iter()
437                .find(|(key, _)| *key == k)
438                .map(|(_, v)| (*v).to_string())
439        });
440        match config.auth_method {
441            GitAuthMethod::Token { token, .. } => {
442                assert_eq!(token, "ghp_test");
443            }
444            _ => panic!("Expected Token auth method"),
445        }
446    }
447
448    #[test]
449    fn test_cloud_disabled_validation_passes() {
450        let config = CloudConfig::disabled();
451        assert!(
452            config.validate().is_ok(),
453            "Disabled cloud config should always validate"
454        );
455    }
456
457    #[test]
458    fn test_cloud_validation_rejects_non_https_api_url() {
459        let config = CloudConfig {
460            enabled: true,
461            api_url: Some("http://api.example.com".to_string()),
462            api_token: Some("secret".to_string()),
463            run_id: Some("run123".to_string()),
464            heartbeat_interval_secs: 30,
465            graceful_degradation: true,
466            git_remote: GitRemoteConfig::default(),
467        };
468        assert!(
469            config.validate().is_err(),
470            "Cloud API URL must be https:// when cloud mode is enabled"
471        );
472    }
473
474    #[test]
475    fn test_cloud_validation_requires_git_token_for_token_auth() {
476        let config = CloudConfig {
477            enabled: true,
478            api_url: Some("https://api.example.com".to_string()),
479            api_token: Some("secret".to_string()),
480            run_id: Some("run123".to_string()),
481            heartbeat_interval_secs: 30,
482            graceful_degradation: true,
483            git_remote: GitRemoteConfig {
484                auth_method: GitAuthMethod::Token {
485                    token: String::new(),
486                    username: "x-access-token".to_string(),
487                },
488                ..GitRemoteConfig::default()
489            },
490        };
491        assert!(
492            config.validate().is_err(),
493            "Token auth requires a non-empty RALPH_GIT_TOKEN"
494        );
495    }
496}