Skip to main content

stackless_provider_sdk/
resource.rs

1//! The generic catalog-resource integration: declare a config + output fields +
2//! a provider prefix, and get `ProviderOps` (provision/observe/destroy) for free.
3//!
4//! This is the default shape for any provider whose credentials come back as
5//! several flat env vars (Cloudflare R2/KV/D1/Workers/…). Stripe names them
6//! `{RESOURCE}_{SUFFIX}` (when several resources share an environment) or
7//! `{PROVIDER}_{SUFFIX}` (when unambiguous); the shared resolution handles both.
8//! Providers with a single bespoke credential blob (Clerk) stay custom.
9
10use std::collections::BTreeMap;
11use std::path::Path;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use stackless_core::def::{Namespace, StackDef};
16use stackless_core::substrate::StepResource;
17use stackless_core::types::DnsName;
18use stackless_stripe_projects::catalog::verify::CatalogService;
19use stackless_stripe_projects::project;
20use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs};
21use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects};
22
23use crate::ProviderOps;
24use crate::config;
25use crate::error::IntegrationError;
26use crate::hostable::Hostable;
27use crate::observation::IntegrationObservation;
28
29/// A catalog resource: a typed config + the credential fields it exposes. The
30/// `ProviderOps` lifecycle is derived (blanket impl below), so a new resource is
31/// just this trait + a `Hostable` + one registry row.
32pub trait CatalogResource: Hostable {
33    // `Send + Sync` so the generic `ProviderOps` future (which holds the config
34    // and a `&config` across awaits) is `Send` for the boxed `async_trait`.
35    type Config: CatalogService + Send + Sync;
36    /// The env-var prefix the provider uses for the unambiguous form, e.g.
37    /// `"CLOUDFLARE"` (vars come back as `{RESOURCE}_{SUFFIX}` or `{PROVIDER}_{SUFFIX}`).
38    const PROVIDER_PREFIX: &'static str;
39    /// `(env-var suffix, output name, required)`. Discover the real suffixes with
40    /// `xtask discover <reference>`; pinned by the live smoke.
41    const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)];
42
43    fn build_config(ctx: &ProvisionContext<'_>) -> Result<Self::Config, IntegrationError>;
44}
45
46/// The checkpoint payload for any catalog resource: the Stripe resource name
47/// (for observe/destroy) and the `${integrations.<name>.<output>}` map.
48#[derive(Debug, Serialize, Deserialize)]
49pub struct ResourcePayload {
50    pub stripe_resource: String,
51    pub outputs: BTreeMap<String, String>,
52}
53
54/// Every `CatalogResource` gets its `ProviderOps` for free — the lifecycle is
55/// identical, so a per-resource impl would be pure boilerplate. (Does not
56/// conflict with Clerk: `ClerkAuth` is not a `CatalogResource`.)
57#[async_trait]
58impl<T: CatalogResource + Send + Sync> ProviderOps for T {
59    #[allow(clippy::too_many_arguments)]
60    async fn provision(
61        &self,
62        stripe: &StripeProjects<&dyn CommandRunner>,
63        def: &StackDef,
64        definition_dir: &Path,
65        instance: &str,
66        name: &str,
67        substrate: &str,
68        skip_stripe_instance_context: bool,
69    ) -> Result<StepResource, IntegrationError> {
70        provision_resource::<T>(
71            stripe,
72            def,
73            definition_dir,
74            instance,
75            name,
76            substrate,
77            skip_stripe_instance_context,
78        )
79        .await
80    }
81
82    async fn observe(
83        &self,
84        stripe: &StripeProjects<&dyn CommandRunner>,
85        checkpoint_payload: &str,
86        fallback_resource: &str,
87    ) -> Result<IntegrationObservation, IntegrationError> {
88        observe_resource(stripe, checkpoint_payload, fallback_resource).await
89    }
90
91    async fn destroy(
92        &self,
93        stripe: &StripeProjects<&dyn CommandRunner>,
94        checkpoint_payload: &str,
95        fallback_resource: &str,
96    ) -> Result<(), IntegrationError> {
97        destroy_resource(stripe, checkpoint_payload, fallback_resource).await
98    }
99}
100
101/// Provision a resource: build config, add the catalog resource (validated +
102/// paid-confirmed by the catalog tier), resolve its declared output fields
103/// (shared dual-form resolution), and record them.
104#[allow(clippy::too_many_arguments)]
105pub async fn provision_resource<T: CatalogResource>(
106    stripe: &StripeProjects<&dyn CommandRunner>,
107    def: &StackDef,
108    definition_dir: &Path,
109    instance: &str,
110    name: &str,
111    substrate: &str,
112    skip_instance_context: bool,
113) -> Result<StepResource, IntegrationError> {
114    if !def.integrations.contains_key(name) {
115        return Err(IntegrationError::ConfigInvalid {
116            location: format!("integrations.{name}"),
117            detail: "integration not in definition".into(),
118        });
119    }
120    let ctx = ProvisionContext {
121        def,
122        instance,
123        logical_name: name,
124        definition_dir,
125        substrate,
126        skip_instance_context,
127    };
128    let config = T::build_config(&ctx)?;
129    let catalog = stripe.catalog_for_reference(T::Config::REFERENCE).await?;
130    let (resource_name, outputs) = provision_outputs(
131        stripe,
132        &catalog,
133        &ctx,
134        &config,
135        T::PROVIDER_PREFIX,
136        T::OUTPUT_FIELDS,
137    )
138    .await?;
139    let payload = ResourcePayload {
140        stripe_resource: resource_name.clone(),
141        outputs,
142    };
143    Ok(StepResource {
144        resource_kind: T::RESOURCE_KIND.into(),
145        resource_id: resource_name,
146        payload: serde_json::to_string(&payload).unwrap_or_default(),
147    })
148}
149
150/// Re-check a recorded resource against Stripe Projects.
151pub async fn observe_resource(
152    stripe: &StripeProjects<&dyn CommandRunner>,
153    checkpoint_payload: &str,
154    fallback_resource: &str,
155) -> Result<IntegrationObservation, IntegrationError> {
156    let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
157    let present = project::resource_registered(stripe, &resource).await?;
158    Ok(if present {
159        IntegrationObservation::Present { drift: vec![] }
160    } else {
161        IntegrationObservation::Gone
162    })
163}
164
165/// Remove a recorded resource from Stripe Projects.
166pub async fn destroy_resource(
167    stripe: &StripeProjects<&dyn CommandRunner>,
168    checkpoint_payload: &str,
169    fallback_resource: &str,
170) -> Result<(), IntegrationError> {
171    let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
172    project::remove_resource(stripe, &resource).await?;
173    Ok(())
174}
175
176fn stripe_resource(payload: &str) -> Option<String> {
177    serde_json::from_str::<ResourcePayload>(payload)
178        .ok()
179        .map(|payload| payload.stripe_resource)
180}
181
182/// The integration's effective config. Resource integrations are `Managed`
183/// (`GlobalOnly`), so there are no per-host override blocks to strip.
184pub fn integration_config(
185    ctx: &ProvisionContext<'_>,
186) -> Result<BTreeMap<String, toml::Value>, IntegrationError> {
187    let spec = ctx.def.integrations.get(ctx.logical_name).ok_or_else(|| {
188        IntegrationError::ConfigInvalid {
189            location: format!("integrations.{}", ctx.logical_name),
190            detail: "integration not in definition".into(),
191        }
192    })?;
193    Ok(spec.effective_config(ctx.substrate, &[]))
194}
195
196/// Read a required string field and interpolate `${...}` references.
197pub fn interp_required(
198    ctx: &ProvisionContext<'_>,
199    config: &BTreeMap<String, toml::Value>,
200    key: &str,
201) -> Result<String, IntegrationError> {
202    let raw =
203        config::config_string(config, key).map_err(|err| cfg_invalid(ctx, key, err.to_string()))?;
204    interp_value(ctx, key, &raw)
205}
206
207/// Read an optional string field and interpolate it when present.
208pub fn interp_optional(
209    ctx: &ProvisionContext<'_>,
210    config: &BTreeMap<String, toml::Value>,
211    key: &str,
212) -> Result<Option<String>, IntegrationError> {
213    match config::config_optional_string(config, key) {
214        None => Ok(None),
215        Some(raw) => Ok(Some(interp_value(ctx, key, &raw)?)),
216    }
217}
218
219fn interp_value(
220    ctx: &ProvisionContext<'_>,
221    key: &str,
222    raw: &str,
223) -> Result<String, IntegrationError> {
224    let namespace = Namespace {
225        stack_name: ctx.def.stack.name.clone(),
226        instance_name: DnsName::from_stored(ctx.instance),
227        ..Namespace::default()
228    };
229    let location = format!("integrations.{}.{key}", ctx.logical_name);
230    stackless_core::def::interp::resolve(raw, &namespace, &location)
231        .map_err(|err| cfg_invalid(ctx, key, err.to_string()))
232}
233
234/// Read a required integer field (e.g. a port) from the effective config.
235pub fn int_required(
236    ctx: &ProvisionContext<'_>,
237    config: &BTreeMap<String, toml::Value>,
238    key: &str,
239) -> Result<i64, IntegrationError> {
240    config
241        .get(key)
242        .and_then(toml::Value::as_integer)
243        .ok_or_else(|| {
244            cfg_invalid(
245                ctx,
246                key,
247                format!("{key} is required and must be an integer"),
248            )
249        })
250}
251
252/// Read an optional integer field from the effective config.
253pub fn int_optional(
254    ctx: &ProvisionContext<'_>,
255    config: &BTreeMap<String, toml::Value>,
256    key: &str,
257) -> Result<Option<i64>, IntegrationError> {
258    match config.get(key) {
259        None => Ok(None),
260        Some(value) => value
261            .as_integer()
262            .map(Some)
263            .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} must be an integer when set"))),
264    }
265}
266
267/// Read a required boolean field from the effective config.
268pub fn bool_required(
269    ctx: &ProvisionContext<'_>,
270    config: &BTreeMap<String, toml::Value>,
271    key: &str,
272) -> Result<bool, IntegrationError> {
273    config
274        .get(key)
275        .and_then(toml::Value::as_bool)
276        .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} is required and must be a boolean")))
277}
278
279/// Read an optional boolean field from the effective config.
280pub fn bool_optional(
281    ctx: &ProvisionContext<'_>,
282    config: &BTreeMap<String, toml::Value>,
283    key: &str,
284) -> Result<Option<bool>, IntegrationError> {
285    match config.get(key) {
286        None => Ok(None),
287        Some(value) => value
288            .as_bool()
289            .map(Some)
290            .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} must be a boolean when set"))),
291    }
292}
293
294fn cfg_invalid(ctx: &ProvisionContext<'_>, key: &str, detail: String) -> IntegrationError {
295    IntegrationError::ConfigInvalid {
296        location: format!("integrations.{}.{key}", ctx.logical_name),
297        detail,
298    }
299}