Skip to main content

stackless_stripe_projects/
project.rs

1//! Stripe Projects orchestration: project anchor, per-instance environments,
2//! resource add/remove, env materialization, and spend reporting.
3
4use std::collections::BTreeMap;
5use std::path::Path;
6use std::time::Duration;
7
8use serde_json::Value;
9use stackless_core::def::StackDef;
10
11use crate::error::ProjectsError;
12use crate::responses::{
13    EnvListResponse, ServicesListResponse, StatusResponse, preflight_checks_from_parts,
14};
15use crate::stripe::{CommandRunner, StripeProjects};
16
17/// Shared flags for `init --preflight` (doctor prefixes `projects` / `--json`;
18/// [`run_init_preflight`] uses `stripe.json` which supplies those).
19pub const INIT_PREFLIGHT_FLAGS: &[&str] =
20    &["--preflight", "--skip-skills", "--accept-tos", "--yes"];
21
22/// The recorded Stripe Projects anchor from `[stack.projects.stripe].project`.
23pub fn recorded_project_id(def: &StackDef) -> Option<String> {
24    def.stack
25        .projects
26        .stripe
27        .as_ref()
28        .and_then(|stripe| stripe.project.clone())
29}
30
31pub async fn ensure_project<R: CommandRunner>(
32    stripe: &StripeProjects<R>,
33    def: &StackDef,
34    definition_dir: &Path,
35) -> Result<(), ProjectsError> {
36    let recorded = recorded_project_id(def);
37    let status = stripe.json(&["status"]).await?;
38    let linked = serde_json::from_value::<StatusResponse>(status.data)
39        .ok()
40        .and_then(|s| s.project_id().map(str::to_owned));
41
42    match (&recorded, &linked) {
43        (Some(want), Some(have)) if want == have => Ok(()),
44        (Some(want), _) => {
45            stripe
46                .run_ok(
47                    "pull",
48                    &["pull", want, "--skip-skills", "--yes"],
49                    &["--yes"],
50                )
51                .await?;
52            Ok(())
53        }
54        (None, Some(have)) => {
55            write_project_anchor(definition_dir, have)?;
56            Ok(())
57        }
58        (None, None) => {
59            run_init_preflight(stripe, def.stack.name.as_str()).await?;
60            stripe
61                .run_ok(
62                    "init",
63                    &[
64                        "init",
65                        def.stack.name.as_str(),
66                        "--skip-skills",
67                        "--accept-tos",
68                    ],
69                    &["--accept-tos", "--yes"],
70                )
71                .await?;
72            let status = stripe.json(&["status"]).await?;
73            let id = serde_json::from_value::<StatusResponse>(status.data)
74                .ok()
75                .and_then(|s| s.project_id().map(str::to_owned))
76                .ok_or_else(|| ProjectsError::ProjectAnchor {
77                    detail: "created project but status reported no id".into(),
78                })?;
79            write_project_anchor(definition_dir, &id)?;
80            Ok(())
81        }
82    }
83}
84
85pub fn write_project_anchor(definition_dir: &Path, project_id: &str) -> Result<(), ProjectsError> {
86    let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(definition_dir);
87    let _guard = stackless_core::lockfile::FileLock::acquire_with_wait(
88        &lock_path,
89        Duration::from_secs(30 * 60),
90    )
91    .map_err(|err| ProjectsError::LockHeld {
92        definition_dir: definition_dir.display().to_string(),
93        detail: err.to_string(),
94    })?;
95    let path = definition_dir.join("stackless.toml");
96    let text = std::fs::read_to_string(&path).map_err(|err| ProjectsError::ProjectAnchor {
97        detail: format!("cannot read {}: {err}", path.display()),
98    })?;
99    let mut doc =
100        text.parse::<toml_edit::DocumentMut>()
101            .map_err(|err| ProjectsError::ProjectAnchor {
102                detail: format!("cannot parse {}: {err}", path.display()),
103            })?;
104    let stack = doc["stack"].or_insert(toml_edit::table());
105    if let Some(stack_table) = stack.as_table_mut() {
106        stack_table.set_implicit(false);
107    }
108    let projects = doc["stack"]["projects"].or_insert(toml_edit::table());
109    if let Some(projects_table) = projects.as_table_mut() {
110        projects_table.set_implicit(false);
111    }
112    let stripe = doc["stack"]["projects"]["stripe"].or_insert(toml_edit::table());
113    if let Some(stripe_table) = stripe.as_table_mut() {
114        stripe_table.set_implicit(false);
115    }
116    doc["stack"]["projects"]["stripe"]["project"] = toml_edit::value(project_id);
117    std::fs::write(&path, doc.to_string()).map_err(|err| ProjectsError::ProjectAnchor {
118        detail: format!("cannot write {}: {err}", path.display()),
119    })?;
120    Ok(())
121}
122
123pub async fn ensure_environment<R: CommandRunner>(
124    stripe: &StripeProjects<R>,
125    instance: &str,
126) -> Result<(), ProjectsError> {
127    let list = stripe.json(&["env", "list"]).await?;
128    let exists = serde_json::from_value::<EnvListResponse>(list.data)
129        .map(|response| response.contains(instance))
130        .unwrap_or(false);
131    if exists {
132        stripe
133            .run_ok("env use", &["env", "use", instance], &["--yes"])
134            .await?;
135    } else {
136        let output = format!(".env.{instance}");
137        stripe
138            .run_ok(
139                "env create",
140                &["env", "create", instance, "--output", &output, "--yes"],
141                &["--yes"],
142            )
143            .await?;
144    }
145    Ok(())
146}
147
148pub async fn resource_registered<R: CommandRunner>(
149    stripe: &StripeProjects<R>,
150    name: &str,
151) -> Result<bool, ProjectsError> {
152    let result = stripe.json(&["services", "list"]).await?;
153    if !result.ok {
154        return Ok(false);
155    }
156    Ok(serde_json::from_value::<ServicesListResponse>(result.data)
157        .map(|response| response.contains(name))
158        .unwrap_or(false))
159}
160
161pub async fn add_resource<R: CommandRunner>(
162    stripe: &StripeProjects<R>,
163    reference: &str,
164    name: &str,
165    config: &Value,
166    paid: bool,
167) -> Result<Value, ProjectsError> {
168    if resource_registered(stripe, name).await? {
169        return Ok(Value::Null);
170    }
171    let config_str = config.to_string();
172    let mut args: Vec<&str> = vec![
173        "add",
174        reference,
175        "--name",
176        name,
177        "--config",
178        &config_str,
179        "--accept-tos",
180        "--yes",
181    ];
182    if paid {
183        args.push("--confirm-paid-service");
184    }
185    let plain_extra = if paid {
186        vec!["--accept-tos", "--yes", "--confirm-paid-service"]
187    } else {
188        vec!["--accept-tos", "--yes"]
189    };
190    let data = stripe
191        .run_ok(&format!("add {reference}"), &args, &plain_extra)
192        .await?;
193    stripe
194        .run_ok(
195            &format!("env add {name}"),
196            &["env", "add", name, "--resource"],
197            &["--yes"],
198        )
199        .await?;
200    Ok(data)
201}
202
203pub async fn refreshed_env_value<R: CommandRunner>(
204    stripe: &StripeProjects<R>,
205    service_reference: &str,
206    key: &str,
207) -> Result<Option<String>, ProjectsError> {
208    let data = stripe
209        .run_ok(
210            "env",
211            &["env", "--service", service_reference, "--refresh"],
212            &["--yes"],
213        )
214        .await?;
215    Ok(find_env_value(&data, key))
216}
217
218pub async fn pull_env_value<R: CommandRunner>(
219    stripe: &StripeProjects<R>,
220    instance: &str,
221    key: &str,
222) -> Result<Option<String>, ProjectsError> {
223    Ok(pull_env_values(stripe, instance, &[key])
224        .await?
225        .into_iter()
226        .next()
227        .flatten())
228}
229
230/// Pull the instance's env once and read several keys from it, returning one
231/// `Option<String>` per key in input order. Values are read from on-disk vault
232/// files after `env --pull` — the plugin still redacts values in JSON at 0.23.0.
233pub async fn pull_env_values<R: CommandRunner>(
234    stripe: &StripeProjects<R>,
235    instance: &str,
236    keys: &[&str],
237) -> Result<Vec<Option<String>>, ProjectsError> {
238    refresh_vault(stripe).await?;
239    let map = vault_env_from_dir(stripe.dir(), Some(instance));
240    let values = keys.iter().map(|&key| map.get(key).cloned()).collect();
241    Ok(values)
242}
243
244pub fn find_env_value(value: &Value, key: &str) -> Option<String> {
245    match value {
246        Value::Object(map) => {
247            if let Some(found) = map.get(key).and_then(Value::as_str)
248                && !is_redacted(found)
249            {
250                return Some(found.to_owned());
251            }
252            let named_key = map
253                .get("key")
254                .or_else(|| map.get("name"))
255                .and_then(Value::as_str);
256            if named_key == Some(key)
257                && let Some(found) = map.get("value").and_then(Value::as_str)
258                && !is_redacted(found)
259            {
260                return Some(found.to_owned());
261            }
262            map.values().find_map(|child| find_env_value(child, key))
263        }
264        Value::Array(values) => values.iter().find_map(|child| find_env_value(child, key)),
265        _ => None,
266    }
267}
268
269fn is_redacted(value: &str) -> bool {
270    let lower = value.to_ascii_lowercase();
271    value.contains('•')
272        || value.contains('*')
273        || lower.contains("redacted")
274        || lower.contains("hidden")
275}
276
277pub fn unquote_env_value(value: &str) -> String {
278    let bytes = value.as_bytes();
279    if bytes.len() >= 2
280        && ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
281            || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'))
282    {
283        value[1..value.len() - 1].to_owned()
284    } else {
285        value.to_owned()
286    }
287}
288
289pub async fn remove_resource<R: CommandRunner>(
290    stripe: &StripeProjects<R>,
291    resource: &str,
292) -> Result<(), ProjectsError> {
293    // Idempotent teardown: a resource that is no longer registered is already
294    // gone, and `stripe projects remove` would fail it with RESOURCE_NOT_FOUND.
295    // Skipping keeps `down` retryable (the engine re-runs destroy on survivors).
296    if !resource_registered(stripe, resource).await? {
297        return Ok(());
298    }
299    stripe
300        .run_ok(
301            &format!("remove {resource}"),
302            &["remove", resource, "--yes", "--force"],
303            &["--yes", "--force"],
304        )
305        .await?;
306    Ok(())
307}
308
309pub async fn delete_environment<R: CommandRunner>(
310    stripe: &StripeProjects<R>,
311    instance: &str,
312) -> Result<(), ProjectsError> {
313    stripe
314        .run_ok(
315            &format!("env delete {instance}"),
316            &["env", "delete", instance, "--yes"],
317            &["--yes"],
318        )
319        .await?;
320    Ok(())
321}
322
323pub async fn set_spend_cap<R: CommandRunner>(
324    stripe: &StripeProjects<R>,
325    limit_usd: u32,
326    provider: &str,
327) -> Result<(), ProjectsError> {
328    let limit = limit_usd.to_string();
329    stripe
330        .run_ok(
331            "billing update",
332            &[
333                "billing",
334                "update",
335                "--limit",
336                &limit,
337                "--provider",
338                provider,
339                "--yes",
340            ],
341            &["--yes"],
342        )
343        .await?;
344    Ok(())
345}
346
347pub async fn spend_summary<R: CommandRunner>(stripe: &StripeProjects<R>) -> Option<String> {
348    let result = stripe.json(&["spend"]).await.ok()?;
349    if !result.ok {
350        return None;
351    }
352    Some(result.data.to_string())
353}
354
355/// Run `init --preflight` before project creation so auth/eligibility blockers
356/// surface once instead of mid-init. Uses the same consent flags as real `init`.
357pub async fn run_init_preflight<R: CommandRunner>(
358    stripe: &StripeProjects<R>,
359    stack_name: &str,
360) -> Result<(), ProjectsError> {
361    let mut args = Vec::with_capacity(2 + INIT_PREFLIGHT_FLAGS.len());
362    args.push("init");
363    args.push(stack_name);
364    args.extend_from_slice(INIT_PREFLIGHT_FLAGS);
365    let result = stripe.json(&args).await?;
366    if result.ok {
367        return Ok(());
368    }
369    Err(preflight_failure("init", &result))
370}
371
372async fn refresh_vault<R: CommandRunner>(stripe: &StripeProjects<R>) -> Result<(), ProjectsError> {
373    stripe
374        .run_ok("env --pull", &["env", "--pull", "--refresh"], &["--yes"])
375        .await?;
376    Ok(())
377}
378
379const EMPTY_ENV_PULL_CODE: &str = "PROJECT_ENVIRONMENT_HAS_NO_RESOURCES";
380
381/// Select the target instance environment, then refresh its vault files.
382///
383/// An environment that exists but has no resources/variables yet
384/// (`PROJECT_ENVIRONMENT_HAS_NO_RESOURCES`) is a soft success: first `up` /
385/// post-`down` re-`up` must not fail before integrations can re-provision.
386/// Clears a stale `.env.<instance>` so prior credentials cannot leak.
387///
388/// Post-provision pulls via [`refresh_vault`] stay strict.
389pub async fn sync_vault_pull_for_instance<R: CommandRunner>(
390    stripe: &StripeProjects<R>,
391    instance: &str,
392) -> Result<(), ProjectsError> {
393    ensure_environment(stripe, instance).await?;
394    match refresh_vault(stripe).await {
395        Ok(()) => Ok(()),
396        Err(ProjectsError::Failed { detail, .. }) if detail.contains(EMPTY_ENV_PULL_CODE) => {
397            clear_stale_instance_env(stripe.dir(), instance);
398            Ok(())
399        }
400        Err(err) => Err(err),
401    }
402}
403
404fn clear_stale_instance_env(definition_dir: &Path, instance: &str) {
405    let path = definition_dir.join(format!(".env.{instance}"));
406    let _ = std::fs::remove_file(&path);
407}
408
409/// Whether `.projects/` exists under `definition_dir` (created by `init`).
410/// Vault pull before first `up` must skip until this exists.
411pub fn project_initialized_in_dir(definition_dir: &Path) -> bool {
412    definition_dir.join(".projects").is_dir()
413}
414
415/// Read env keys from pulled vault files. Scans `.env` then `.env.<instance>`
416/// (instance overrides base). Does not call the Stripe CLI.
417pub fn vault_env_from_dir(
418    definition_dir: &Path,
419    instance: Option<&str>,
420) -> BTreeMap<String, String> {
421    let mut out = BTreeMap::new();
422    let base = definition_dir.join(".env");
423    if let Ok(text) = std::fs::read_to_string(&base) {
424        merge_env_lines(&mut out, &text);
425    }
426    if let Some(instance) = instance {
427        let inst = definition_dir.join(format!(".env.{instance}"));
428        if let Ok(text) = std::fs::read_to_string(inst) {
429            merge_env_lines(&mut out, &text);
430        }
431    }
432    out
433}
434
435/// Parse `KEY=VALUE` lines into `out` (comments and blank lines skipped).
436pub fn merge_env_lines(out: &mut BTreeMap<String, String>, text: &str) {
437    for line in text.lines() {
438        let line = line.trim();
439        if line.is_empty() || line.starts_with('#') {
440            continue;
441        }
442        let Some((key, value)) = line.split_once('=') else {
443            continue;
444        };
445        out.insert(key.trim().to_owned(), unquote_env_value(value.trim()));
446    }
447}
448
449fn preflight_failure(command: &str, result: &crate::stripe::StripeResult) -> ProjectsError {
450    let checks =
451        preflight_checks_from_parts(&result.data, result.ok, result.error_details.as_ref());
452    let detail = if checks.iter().any(|c| !c.pass) {
453        checks
454            .iter()
455            .filter(|c| !c.pass)
456            .map(|c| {
457                let remedy = c.remedy.as_deref().unwrap_or("");
458                if remedy.is_empty() {
459                    c.label.clone()
460                } else {
461                    format!("{} — {remedy}", c.label)
462                }
463            })
464            .collect::<Vec<_>>()
465            .join("; ")
466    } else {
467        result
468            .error_message
469            .clone()
470            .unwrap_or_else(|| "preflight blocked".into())
471    };
472    ProjectsError::Failed {
473        command: command.to_owned(),
474        detail,
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::stripe::{CommandOutput, CommandRunner, StripeProjects};
482    use crate::test_support::{ScriptedRunner, env_list, ok_empty};
483    use async_trait::async_trait;
484    use serde_json::json;
485
486    #[test]
487    fn anchor_writeback_preserves_comments_and_adds_neutral_project() {
488        let dir = tempfile::tempdir().unwrap();
489        let path = dir.path().join("stackless.toml");
490        std::fs::write(
491            &path,
492            "# atto dogfood\n[stack]\nname = \"atto\"\n\n[stack.render]\nregion = \"oregon\"\n",
493        )
494        .unwrap();
495
496        write_project_anchor(dir.path(), "project_abc123").unwrap();
497
498        let after = std::fs::read_to_string(&path).unwrap();
499        assert!(after.contains("# atto dogfood"));
500        assert!(after.contains("project = \"project_abc123\""));
501
502        let doc: toml::Value = toml::from_str(&after).unwrap();
503        assert_eq!(
504            doc["stack"]["projects"]["stripe"]["project"].as_str(),
505            Some("project_abc123")
506        );
507    }
508
509    struct ListRunner {
510        body: String,
511        calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
512    }
513
514    #[async_trait]
515    impl CommandRunner for ListRunner {
516        async fn run(
517            &self,
518            _args: &[String],
519            _cwd: &std::path::Path,
520        ) -> Result<CommandOutput, ProjectsError> {
521            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
522            Ok(CommandOutput {
523                status: 0,
524                stdout: self.body.clone(),
525                stderr: String::new(),
526            })
527        }
528    }
529
530    #[tokio::test]
531    async fn add_resource_propagates_env_add_failure() {
532        struct FailEnvAddRunner;
533
534        #[async_trait]
535        impl CommandRunner for FailEnvAddRunner {
536            async fn run(
537                &self,
538                args: &[String],
539                _cwd: &std::path::Path,
540            ) -> Result<CommandOutput, ProjectsError> {
541                if args.iter().any(|a| a == "list") {
542                    return Ok(CommandOutput {
543                        status: 0,
544                        stdout: r#"{"ok":true,"data":{"services":[]}}"#.into(),
545                        stderr: String::new(),
546                    });
547                }
548                if args.iter().any(|a| a == "add") && args.iter().any(|a| a == "--resource") {
549                    return Ok(CommandOutput {
550                        status: 0,
551                        stdout: r#"{"ok":false,"error":{"message":"member missing"}}"#.into(),
552                        stderr: String::new(),
553                    });
554                }
555                Ok(CommandOutput {
556                    status: 0,
557                    stdout: r#"{"ok":true,"data":{}}"#.into(),
558                    stderr: String::new(),
559                })
560            }
561        }
562
563        let stripe = StripeProjects::new(FailEnvAddRunner, std::env::temp_dir());
564        let err = add_resource(
565            &stripe,
566            "render/static-site",
567            "demo-web",
568            &serde_json::json!({}),
569            false,
570        )
571        .await
572        .unwrap_err();
573        assert!(matches!(err, ProjectsError::Failed { .. }));
574    }
575
576    #[test]
577    fn vault_env_from_dir_prefers_instance_file() {
578        let dir = tempfile::tempdir().unwrap();
579        std::fs::write(dir.path().join(".env"), "KEY=base\n").unwrap();
580        std::fs::write(dir.path().join(".env.demo"), "KEY=instance\n").unwrap();
581        let map = vault_env_from_dir(dir.path(), Some("demo"));
582        assert_eq!(map.get("KEY").map(String::as_str), Some("instance"));
583    }
584
585    #[tokio::test]
586    async fn add_resource_skips_when_already_registered() {
587        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
588        let runner = ListRunner {
589            body: r#"{"ok":true,"data":{"services":[{"name":"atto-cloud-web"}]}}"#.to_owned(),
590            calls: calls.clone(),
591        };
592        let stripe = StripeProjects::new(runner, std::env::temp_dir());
593        add_resource(
594            &stripe,
595            "render/static-site",
596            "atto-cloud-web",
597            &serde_json::json!({}),
598            false,
599        )
600        .await
601        .unwrap();
602        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
603    }
604
605    #[tokio::test]
606    async fn sync_vault_pull_soft_succeeds_on_empty_environment() {
607        let dir = tempfile::tempdir().unwrap();
608        let stale = dir.path().join(".env.demo");
609        std::fs::write(&stale, "CLERK_AUTH_ENVIRONMENTS=stale\n").unwrap();
610        let runner = ScriptedRunner::new(vec![
611            env_list(&["demo"]),
612            ok_empty(), // env use
613            CommandOutput {
614                status: 0,
615                stdout: json!({
616                    "ok": false,
617                    "error": {
618                        "code": "PROJECT_ENVIRONMENT_HAS_NO_RESOURCES",
619                        "message": "environment has no resources"
620                    }
621                })
622                .to_string(),
623                stderr: String::new(),
624            },
625        ]);
626        let stripe = StripeProjects::new(&runner, dir.path());
627        sync_vault_pull_for_instance(&stripe, "demo").await.unwrap();
628        assert!(!stale.exists(), "stale instance env file must be cleared");
629    }
630
631    #[tokio::test]
632    async fn sync_vault_pull_still_fails_on_other_errors() {
633        let dir = tempfile::tempdir().unwrap();
634        let runner = ScriptedRunner::new(vec![
635            env_list(&["demo"]),
636            ok_empty(),
637            CommandOutput {
638                status: 0,
639                stdout: json!({
640                    "ok": false,
641                    "error": {
642                        "code": "SOME_OTHER_FAILURE",
643                        "message": "boom"
644                    }
645                })
646                .to_string(),
647                stderr: String::new(),
648            },
649        ]);
650        let stripe = StripeProjects::new(&runner, dir.path());
651        let err = sync_vault_pull_for_instance(&stripe, "demo")
652            .await
653            .unwrap_err();
654        assert!(matches!(err, ProjectsError::Failed { .. }));
655        assert!(err.to_string().contains("SOME_OTHER_FAILURE"));
656    }
657}