Skip to main content

stackless_stripe_projects/
provision.rs

1//! Catalog-anchored provisioning helpers shared by provider plugins.
2//!
3//! The catalog-add boundary itself lives in [`crate::catalog::verify`]. This
4//! module adds the instance-context (project + environment) and the env-blob
5//! credential resolution that credential-bearing services (e.g. Clerk) need.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use serde_json::Value;
11use stackless_core::def::StackDef;
12
13use crate::catalog::Catalog;
14use crate::catalog::verify::{CatalogService, add_catalog_resource};
15use crate::error::ProjectsError;
16use crate::project::{self, find_env_value};
17use crate::stripe::{CommandRunner, StripeProjects};
18
19/// The definition context a plugin provisions within.
20#[derive(Debug)]
21pub struct ProvisionContext<'a> {
22    pub def: &'a StackDef,
23    pub instance: &'a str,
24    pub logical_name: &'a str,
25    pub definition_dir: &'a Path,
26    pub substrate: &'a str,
27    /// When true, project/env were ensured by the substrate already.
28    pub skip_instance_context: bool,
29}
30
31impl ProvisionContext<'_> {
32    /// The Stripe resource name: `{instance}-{logical_name}`.
33    pub fn resource_name(&self) -> String {
34        format!("{}-{}", self.instance, self.logical_name)
35    }
36}
37
38/// A credential-bearing catalog provision result: the Stripe resource name and
39/// the raw env blob the provider returned (parsed by the caller, which knows the
40/// provider-specific shape — the catalog does not describe output keys).
41#[derive(Debug)]
42pub struct ProvisionedCredentials {
43    pub resource_name: String,
44    pub raw: String,
45}
46
47/// Ensure the project/environment context, add the catalog resource (validated
48/// against the catalog), then resolve the env blob carrying its credentials.
49pub async fn provision_with_credentials<C, R>(
50    stripe: &StripeProjects<R>,
51    catalog: &Catalog,
52    ctx: &ProvisionContext<'_>,
53    config: &C,
54    env_keys: &[&str],
55) -> Result<ProvisionedCredentials, ProjectsError>
56where
57    C: CatalogService,
58    R: CommandRunner,
59{
60    if !ctx.skip_instance_context {
61        project::ensure_project(stripe, ctx.def, ctx.definition_dir).await?;
62        project::ensure_environment(stripe, ctx.instance).await?;
63    }
64    let requested = ctx.resource_name();
65    let added = add_catalog_resource(stripe, catalog, config, &requested).await?;
66    let resource_name = added.name;
67    let raw = resolve_env_blob(
68        stripe,
69        &added.data,
70        ctx.instance,
71        &resource_name,
72        C::REFERENCE,
73        env_keys,
74    )
75    .await?;
76    Ok(ProvisionedCredentials { resource_name, raw })
77}
78
79/// A multi-variable catalog provision result: the resource name and the
80/// requested env vars that were returned (only those found).
81#[derive(Debug)]
82pub struct ProvisionedEnv {
83    pub resource_name: String,
84    pub values: BTreeMap<String, String>,
85}
86
87/// Like [`provision_with_credentials`], but resolves a SET of env vars. Some
88/// providers (e.g. Cloudflare R2) return credentials as several distinct env
89/// vars rather than one blob; each requested key is taken from the add response
90/// if present, else from a single refreshing env pull.
91pub async fn provision_with_env<C, R>(
92    stripe: &StripeProjects<R>,
93    catalog: &Catalog,
94    ctx: &ProvisionContext<'_>,
95    config: &C,
96    env_keys: &[&str],
97) -> Result<ProvisionedEnv, ProjectsError>
98where
99    C: CatalogService,
100    R: CommandRunner,
101{
102    if !ctx.skip_instance_context {
103        project::ensure_project(stripe, ctx.def, ctx.definition_dir).await?;
104        project::ensure_environment(stripe, ctx.instance).await?;
105    }
106    let requested = ctx.resource_name();
107    let added = add_catalog_resource(stripe, catalog, config, &requested).await?;
108    let resource_name = added.name;
109    let mut values = BTreeMap::new();
110    let mut missing: Vec<&str> = Vec::new();
111    for key in env_keys {
112        match find_env_value(&added.data, key) {
113            Some(value) => {
114                values.insert((*key).to_owned(), value);
115            }
116            None => missing.push(key),
117        }
118    }
119    // Only the keys the add response did not carry need a (single) env pull.
120    if !missing.is_empty() {
121        let pulled = project::pull_env_values(stripe, ctx.instance, &missing).await?;
122        for (idx, key) in missing.iter().enumerate() {
123            if let Some(value) = pulled.get(idx).cloned().flatten() {
124                values.insert((*key).to_owned(), value);
125            }
126        }
127    }
128    Ok(ProvisionedEnv {
129        resource_name,
130        values,
131    })
132}
133
134/// Provision a catalog resource and resolve its declared output fields into an
135/// `output -> value` map. Stripe names a resource's env vars `{RESOURCE}_{SUFFIX}`
136/// (when several share an environment) or `{PROVIDER}_{SUFFIX}` (when a resource
137/// is unambiguous) — both forms are resolved. `fields` is `(env suffix, output
138/// name, required)`; a missing required field is an error. This is the default
139/// path for multi-output providers (Clerk's single-JSON-blob is the exception).
140///
141/// Env key prefixes are derived from the **attached** Stripe local name (which
142/// may differ from [`ProvisionContext::resource_name`] on reuse).
143pub async fn provision_outputs<C, R>(
144    stripe: &StripeProjects<R>,
145    catalog: &Catalog,
146    ctx: &ProvisionContext<'_>,
147    config: &C,
148    provider_prefix: &str,
149    fields: &[(&str, &str, bool)],
150) -> Result<(String, BTreeMap<String, String>), ProjectsError>
151where
152    C: CatalogService,
153    R: CommandRunner,
154{
155    if !ctx.skip_instance_context {
156        project::ensure_project(stripe, ctx.def, ctx.definition_dir).await?;
157        project::ensure_environment(stripe, ctx.instance).await?;
158    }
159    let requested = ctx.resource_name();
160    let added = add_catalog_resource(stripe, catalog, config, &requested).await?;
161    let resource_name = added.name;
162    let resource_prefix = resource_name.to_ascii_uppercase().replace('-', "_");
163    let candidates: Vec<String> = fields
164        .iter()
165        .flat_map(|(suffix, _, _)| {
166            [
167                format!("{resource_prefix}_{suffix}"),
168                format!("{provider_prefix}_{suffix}"),
169            ]
170        })
171        .collect();
172    let mut values = BTreeMap::new();
173    let mut missing: Vec<&str> = Vec::new();
174    for key in &candidates {
175        match find_env_value(&added.data, key) {
176            Some(value) => {
177                values.insert(key.clone(), value);
178            }
179            None => missing.push(key.as_str()),
180        }
181    }
182    if !missing.is_empty() {
183        let pulled = project::pull_env_values(stripe, ctx.instance, &missing).await?;
184        for (idx, key) in missing.iter().enumerate() {
185            if let Some(value) = pulled.get(idx).cloned().flatten() {
186                values.insert((*key).to_owned(), value);
187            }
188        }
189    }
190    let mut outputs = BTreeMap::new();
191    for (suffix, output, required) in fields {
192        let value = values
193            .get(&format!("{resource_prefix}_{suffix}"))
194            .or_else(|| values.get(&format!("{provider_prefix}_{suffix}")));
195        match value {
196            Some(value) => {
197                outputs.insert((*output).to_owned(), value.clone());
198            }
199            None if *required => {
200                return Err(ProjectsError::ProvisionFailed {
201                    resource: resource_name.clone(),
202                    detail: format!("provider did not return {suffix} for {resource_name}"),
203                });
204            }
205            None => {}
206        }
207    }
208    Ok((resource_name, outputs))
209}
210
211async fn resolve_env_blob<R>(
212    stripe: &StripeProjects<R>,
213    add_data: &Value,
214    instance: &str,
215    resource: &str,
216    reference: &str,
217    env_keys: &[&str],
218) -> Result<String, ProjectsError>
219where
220    R: CommandRunner,
221{
222    for key in env_keys {
223        if let Some(value) = find_env_value(add_data, key) {
224            return Ok(value);
225        }
226    }
227    for key in env_keys {
228        if let Some(value) = project::refreshed_env_value(stripe, reference, key).await? {
229            return Ok(value);
230        }
231    }
232    for key in env_keys {
233        if let Some(value) = project::pull_env_value(stripe, instance, key).await? {
234            return Ok(value);
235        }
236    }
237    Err(ProjectsError::ProvisionFailed {
238        resource: resource.to_owned(),
239        detail: format!("none of {env_keys:?} was returned or pulled from Stripe Projects"),
240    })
241}