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 resource_name = ctx.resource_name();
65    let add_data = add_catalog_resource(stripe, catalog, config, &resource_name).await?;
66    let raw = resolve_env_blob(
67        stripe,
68        &add_data,
69        ctx.instance,
70        &resource_name,
71        C::REFERENCE,
72        env_keys,
73    )
74    .await?;
75    Ok(ProvisionedCredentials { resource_name, raw })
76}
77
78/// A multi-variable catalog provision result: the resource name and the
79/// requested env vars that were returned (only those found).
80#[derive(Debug)]
81pub struct ProvisionedEnv {
82    pub resource_name: String,
83    pub values: BTreeMap<String, String>,
84}
85
86/// Like [`provision_with_credentials`], but resolves a SET of env vars. Some
87/// providers (e.g. Cloudflare R2) return credentials as several distinct env
88/// vars rather than one blob; each requested key is taken from the add response
89/// if present, else from a single refreshing env pull.
90pub async fn provision_with_env<C, R>(
91    stripe: &StripeProjects<R>,
92    catalog: &Catalog,
93    ctx: &ProvisionContext<'_>,
94    config: &C,
95    env_keys: &[&str],
96) -> Result<ProvisionedEnv, ProjectsError>
97where
98    C: CatalogService,
99    R: CommandRunner,
100{
101    if !ctx.skip_instance_context {
102        project::ensure_project(stripe, ctx.def, ctx.definition_dir).await?;
103        project::ensure_environment(stripe, ctx.instance).await?;
104    }
105    let resource_name = ctx.resource_name();
106    let add_data = add_catalog_resource(stripe, catalog, config, &resource_name).await?;
107    let mut values = BTreeMap::new();
108    let mut missing: Vec<&str> = Vec::new();
109    for key in env_keys {
110        match find_env_value(&add_data, key) {
111            Some(value) => {
112                values.insert((*key).to_owned(), value);
113            }
114            None => missing.push(key),
115        }
116    }
117    // Only the keys the add response did not carry need a (single) env pull.
118    if !missing.is_empty() {
119        let pulled = project::pull_env_values(stripe, ctx.instance, &missing).await?;
120        for (idx, key) in missing.iter().enumerate() {
121            if let Some(value) = pulled.get(idx).cloned().flatten() {
122                values.insert((*key).to_owned(), value);
123            }
124        }
125    }
126    Ok(ProvisionedEnv {
127        resource_name,
128        values,
129    })
130}
131
132/// Provision a catalog resource and resolve its declared output fields into an
133/// `output -> value` map. Stripe names a resource's env vars `{RESOURCE}_{SUFFIX}`
134/// (when several share an environment) or `{PROVIDER}_{SUFFIX}` (when a resource
135/// is unambiguous) — both forms are resolved. `fields` is `(env suffix, output
136/// name, required)`; a missing required field is an error. This is the default
137/// path for multi-output providers (Clerk's single-JSON-blob is the exception).
138pub async fn provision_outputs<C, R>(
139    stripe: &StripeProjects<R>,
140    catalog: &Catalog,
141    ctx: &ProvisionContext<'_>,
142    config: &C,
143    provider_prefix: &str,
144    fields: &[(&str, &str, bool)],
145) -> Result<(String, BTreeMap<String, String>), ProjectsError>
146where
147    C: CatalogService,
148    R: CommandRunner,
149{
150    let resource_prefix = ctx.resource_name().to_ascii_uppercase().replace('-', "_");
151    let candidates: Vec<String> = fields
152        .iter()
153        .flat_map(|(suffix, _, _)| {
154            [
155                format!("{resource_prefix}_{suffix}"),
156                format!("{provider_prefix}_{suffix}"),
157            ]
158        })
159        .collect();
160    let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
161    let ProvisionedEnv {
162        resource_name,
163        values,
164    } = provision_with_env(stripe, catalog, ctx, config, &candidate_refs).await?;
165    let mut outputs = BTreeMap::new();
166    for (suffix, output, required) in fields {
167        let value = values
168            .get(&format!("{resource_prefix}_{suffix}"))
169            .or_else(|| values.get(&format!("{provider_prefix}_{suffix}")));
170        match value {
171            Some(value) => {
172                outputs.insert((*output).to_owned(), value.clone());
173            }
174            None if *required => {
175                return Err(ProjectsError::ProvisionFailed {
176                    resource: resource_name.clone(),
177                    detail: format!("provider did not return {suffix} for {resource_name}"),
178                });
179            }
180            None => {}
181        }
182    }
183    Ok((resource_name, outputs))
184}
185
186async fn resolve_env_blob<R>(
187    stripe: &StripeProjects<R>,
188    add_data: &Value,
189    instance: &str,
190    resource: &str,
191    reference: &str,
192    env_keys: &[&str],
193) -> Result<String, ProjectsError>
194where
195    R: CommandRunner,
196{
197    for key in env_keys {
198        if let Some(value) = find_env_value(add_data, key) {
199            return Ok(value);
200        }
201    }
202    for key in env_keys {
203        if let Some(value) = project::refreshed_env_value(stripe, reference, key).await? {
204            return Ok(value);
205        }
206    }
207    for key in env_keys {
208        if let Some(value) = project::pull_env_value(stripe, instance, key).await? {
209            return Ok(value);
210        }
211    }
212    Err(ProjectsError::ProvisionFailed {
213        resource: resource.to_owned(),
214        detail: format!("none of {env_keys:?} was returned or pulled from Stripe Projects"),
215    })
216}