1use 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
29pub trait CatalogResource: Hostable {
33 type Config: CatalogService + Send + Sync;
36 const PROVIDER_PREFIX: &'static str;
39 const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)];
42
43 fn build_config(ctx: &ProvisionContext<'_>) -> Result<Self::Config, IntegrationError>;
44}
45
46#[derive(Debug, Serialize, Deserialize)]
49pub struct ResourcePayload {
50 pub stripe_resource: String,
51 pub outputs: BTreeMap<String, String>,
52}
53
54#[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#[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
150pub 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
165pub 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
182pub 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
196pub 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
207pub 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
234pub 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
252pub 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
267pub 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
279pub 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}