1use std::collections::BTreeMap;
13
14use stackless_core::def::interp::{self, Reference};
15use stackless_core::def::{Integration, StackDef};
16use stackless_provider_sdk::{
17 BlockedSetting, ConfigScope, Hostable, IntegrationError, IntegrationHosting, ProviderOps,
18 host_bound_hosts, host_bound_supports,
19};
20pub use stackless_provider_sdk::{config_bool, config_optional_string, config_string};
21
22use crate::providers;
23
24type ValidateFn = fn(&str, &BTreeMap<String, toml::Value>) -> Result<(), IntegrationError>;
25
26struct ProviderEntry {
30 provider: &'static str,
31 hosting: IntegrationHosting,
32 config_scope: ConfigScope,
33 resource_kind: &'static str,
34 outputs: &'static [&'static str],
35 blocked_settings: &'static [BlockedSetting],
36 validate_config: ValidateFn,
37 ops: &'static dyn ProviderOps,
38}
39
40const fn provider_entry<T: Hostable>(
41 validate_config: ValidateFn,
42 ops: &'static dyn ProviderOps,
43) -> ProviderEntry {
44 ProviderEntry {
45 provider: T::PROVIDER,
46 hosting: T::HOSTING,
47 config_scope: T::CONFIG_SCOPE,
48 resource_kind: T::RESOURCE_KIND,
49 outputs: T::OUTPUTS,
50 blocked_settings: T::BLOCKED_SETTINGS,
51 validate_config,
52 ops,
53 }
54}
55
56macro_rules! register_providers {
57 ( $( ( $($m:ident)::+ , $T:ident ) ),+ $(,)? ) => {
58 const PROVIDERS: &[ProviderEntry] = &[
59 $(
60 provider_entry::<providers::$($m)::+::$T>(
61 providers::$($m)::+::validate_config,
62 &providers::$($m)::+::$T,
63 ),
64 )+
65 ];
66 };
67}
68
69register_providers! {
70 (clerk, ClerkAuth),
71 (cloudflare::browser_run, CloudflareBrowserRun),
72 (cloudflare::d1, CloudflareD1),
73 (cloudflare::hyperdrive, CloudflareHyperdrive),
74 (cloudflare::kv, CloudflareKv),
75 (cloudflare::queues, CloudflareQueues),
76 (cloudflare::r2, CloudflareR2),
77 (cloudflare::workers, CloudflareWorkers),
78 (cloudflare::workers_ai, CloudflareWorkersAi),
79 (agentmail::api, AgentMailApi),
80 (agentphone::number, AgentPhoneNumber),
81 (amplitude::analytics, AmplitudeAnalytics),
82 (auth0::client, Auth0Client),
83 (base44_projects::app, Base44ProjectsApp),
84 (blaxel::sandbox, BlaxelSandbox),
85 (browserbase::project, BrowserbaseProject),
86 (chatbase::agent, ChatbaseAgent),
87 (clickhouse::cluster, ClickHouseClickhouse),
88 (clickhouse::postgres, ClickHousePostgres),
89 (composio::project, ComposioProject),
90 (customerio::workspace, CustomerioWorkspace),
91 (datadog::observability, DatadogObservability),
92 (depot::api, DepotApi),
93 (e2b::sandbox, E2BSandbox),
94 (elevenlabs::tts, ElevenLabsTts),
95 (exa::api, ExaApi),
96 (firecrawl::api, FirecrawlApi),
97 (flyio::mpg, FlyioMpg),
98 (flyio::sprite, FlyioSprite),
99 (gitlab::project, GitLabProject),
100 (huggingface::bucket, HuggingFaceBucket),
101 (huggingface::platform, HuggingFacePlatform),
102 (inngest::app, InngestApp),
103 (kernel::project, KERNELProject),
104 (laravel_cloud::application, LaravelCloudApplication),
105 (laravel_cloud::mysql, LaravelCloudMysql),
106 (laravel_cloud::valkey, LaravelCloudValkey),
107 (metronome::sandbox, MetronomeSandbox),
108 (mixpanel::analytics, MixpanelAnalytics),
109 (neon::postgres, NeonPostgres),
110 (openrouter::api, OpenRouterApi),
111 (parallel::api, ParallelApi),
112 (planetscale::mysql, PlanetScaleMysql),
113 (planetscale::postgresql, PlanetScalePostgresql),
114 (postalform::mail, PostalFormMail),
115 (posthog::analytics, PostHogAnalytics),
116 (prisma::database, PrismaDatabase),
117 (pydantic::logfire, PydanticLogfire),
118 (railway::bucket, RailwayBucket),
119 (railway::hosting, RailwayHosting),
120 (railway::mongo, RailwayMongo),
121 (railway::postgres, RailwayPostgres),
122 (railway::redis, RailwayRedis),
123 (render_db::postgres, RenderPostgres),
124 (revenuecat::app, RevenuecatApp),
125 (runloop::sandbox, RunloopSandbox),
126 (schematic::schematic_environment, SchematicEnvironment),
127 (sentry::project, SentryProject),
128 (sentry::seer, SentrySeer),
129 (shopify::store, ShopifyStore),
130 (steel::browser, SteelBrowser),
131 (supabase::project, SupabaseProject),
132 (supermemory::memory, SupermemoryMemory),
133 (tabstack::api, TabstackApi),
134 (turso::database, TursoDatabase),
135 (upstash::qstash, UpstashQstash),
136 (upstash::redis, UpstashRedis),
137 (upstash::search, UpstashSearch),
138 (upstash::vector, UpstashVector),
139 (wix::headless, WixHeadless),
140 (wordpress_com::site, WordPressComSite),
141 (workos::auth, WorkOSAuth),
142}
143
144fn lookup(provider: &str) -> Option<&'static ProviderEntry> {
145 PROVIDERS.iter().find(|entry| entry.provider == provider)
146}
147
148pub fn is_integration_resource(kind: &str) -> bool {
149 PROVIDERS.iter().any(|entry| entry.resource_kind == kind)
150}
151
152pub fn known_outputs(provider: &str) -> Option<&'static [&'static str]> {
153 lookup(provider).map(|entry| entry.outputs)
154}
155
156pub fn validate_integration(
157 name: &str,
158 integration: &Integration,
159 active_host: Option<&str>,
160 known_substrates: &[&str],
161) -> Result<(), IntegrationError> {
162 let entry = lookup(&integration.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
163 location: format!("integrations.{name}"),
164 detail: format!("unsupported provider {:?}", integration.provider),
165 })?;
166
167 validate_host_blocks(
168 name,
169 integration,
170 entry.hosting,
171 entry.config_scope,
172 known_substrates,
173 )?;
174
175 if let Some(host) = active_host
176 && matches!(entry.hosting, IntegrationHosting::HostBound(_))
177 && !host_bound_supports(entry.hosting, host)
178 {
179 return Err(IntegrationError::HostUnsupported {
180 provider: integration.provider.clone(),
181 host: host.to_owned(),
182 });
183 }
184
185 let config = match (entry.config_scope, active_host) {
186 (ConfigScope::PerHost, Some(host)) => integration.effective_config(host, known_substrates),
187 _ => integration.config_fields(known_substrates),
188 };
189 (entry.validate_config)(name, &config)?;
190
191 for setting in entry.blocked_settings {
192 if config_bool(&config, setting.key) {
193 return Err(IntegrationError::ConfigInvalid {
194 location: format!("integrations.{name}.{}", setting.key),
195 detail: setting.remediation.to_owned(),
196 });
197 }
198 }
199 Ok(())
200}
201
202pub fn validate_all(
203 def: &StackDef,
204 active_host: Option<&str>,
205 known_substrates: &[&str],
206) -> Result<(), IntegrationError> {
207 for (name, integration) in &def.integrations {
208 validate_integration(name, integration, active_host, known_substrates)?;
209 }
210 validate_integration_outputs(def, known_substrates)?;
211 Ok(())
212}
213
214fn validate_host_blocks(
215 name: &str,
216 integration: &Integration,
217 hosting: IntegrationHosting,
218 scope: ConfigScope,
219 known_substrates: &[&str],
220) -> Result<(), IntegrationError> {
221 for (host, _block) in integration.host_blocks(known_substrates) {
222 if matches!(hosting, IntegrationHosting::Managed) {
223 return Err(IntegrationError::ConfigInvalid {
224 location: format!("integrations.{name}.{host}"),
225 detail: format!(
226 "provider {:?} is managed and does not support per-host configuration",
227 integration.provider
228 ),
229 });
230 }
231 if matches!(scope, ConfigScope::GlobalOnly) {
232 return Err(IntegrationError::ConfigInvalid {
233 location: format!("integrations.{name}.{host}"),
234 detail: format!(
235 "provider {:?} does not support per-host configuration",
236 integration.provider
237 ),
238 });
239 }
240 if !host_bound_supports(hosting, &host) {
241 return Err(IntegrationError::ConfigInvalid {
242 location: format!("integrations.{name}.{host}"),
243 detail: format!(
244 "host {host:?} is not supported by provider {:?}",
245 integration.provider
246 ),
247 });
248 }
249 let _ = host_bound_hosts(hosting);
250 }
251 Ok(())
252}
253
254fn validate_integration_outputs(
255 def: &StackDef,
256 known_substrates: &[&str],
257) -> Result<(), IntegrationError> {
258 let mut locations = Vec::new();
259 if let Some(verify) = &def.stack.verify {
260 for (key, value) in &verify.env {
261 locations.push((format!("stack.verify.env.{key}"), value.clone()));
262 }
263 for (tier, spec) in &verify.tiers {
264 for (key, value) in &spec.env {
265 locations.push((
266 format!("stack.verify.tiers.{tier}.env.{key}"),
267 value.clone(),
268 ));
269 }
270 }
271 }
272 for (service_name, service) in &def.services {
273 for (key, value) in &service.env {
274 locations.push((format!("services.{service_name}.env.{key}"), value.clone()));
275 }
276 for &host in known_substrates {
277 for (key, value) in service.substrate_env(service_name, host).map_err(|err| {
278 IntegrationError::ConfigInvalid {
279 location: format!("services.{service_name}.{host}.env"),
280 detail: err.to_string(),
281 }
282 })? {
283 locations.push((format!("services.{service_name}.{host}.env.{key}"), value));
284 }
285 }
286 }
287 for (name, integration) in &def.integrations {
288 for (key, value) in integration.config_fields(known_substrates) {
289 if let Some(text) = value.as_str() {
290 locations.push((format!("integrations.{name}.{key}"), text.to_owned()));
291 }
292 }
293 }
294
295 for (location, value) in locations {
296 let refs = interp::references(&value, &location).map_err(|err| {
297 IntegrationError::ConfigInvalid {
298 location: location.clone(),
299 detail: err.to_string(),
300 }
301 })?;
302 for reference in refs {
303 let Reference::IntegrationOutput {
304 integration,
305 output,
306 } = reference
307 else {
308 continue;
309 };
310 let Some(spec) = def.integrations.get(&integration) else {
311 continue;
312 };
313 let outputs =
314 known_outputs(&spec.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
315 location: location.clone(),
316 detail: format!("integration {integration:?} has unsupported provider"),
317 })?;
318 if !outputs.contains(&output.as_str()) {
319 return Err(IntegrationError::ConfigInvalid {
320 location,
321 detail: format!(
322 "unknown output {output:?} for integration {integration:?} \
323 (known: {outputs:?})"
324 ),
325 });
326 }
327 }
328 }
329 Ok(())
330}
331
332pub fn dispatch_resource_kind(provider: &str) -> Option<&'static str> {
333 lookup(provider).map(|entry| entry.resource_kind)
334}
335
336pub fn ops_for(provider: &str) -> Option<&'static dyn ProviderOps> {
338 lookup(provider).map(|entry| entry.ops)
339}
340
341pub fn ops_for_resource_kind(kind: &str) -> Option<&'static dyn ProviderOps> {
343 PROVIDERS
344 .iter()
345 .find(|entry| entry.resource_kind == kind)
346 .map(|entry| entry.ops)
347}
348
349pub fn provider_host_keys(provider: &str) -> &'static [&'static str] {
354 lookup(provider)
355 .map(|entry| host_bound_hosts(entry.hosting))
356 .unwrap_or(&[])
357}
358
359#[cfg(test)]
360mod tests {
361 use stackless_core::def::StackDef;
362 use stackless_core::fault::{Fault, codes};
363
364 use super::*;
365
366 const KNOWN: &[&str] = &["local", "render", "vercel", "fly", "netlify"];
367
368 #[test]
371 fn registry_providers_and_resource_kinds_are_unique() {
372 use std::collections::BTreeSet;
373 let providers: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.provider).collect();
374 assert_eq!(
375 providers.len(),
376 PROVIDERS.len(),
377 "duplicate provider string"
378 );
379 let kinds: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.resource_kind).collect();
380 assert_eq!(kinds.len(), PROVIDERS.len(), "duplicate resource_kind");
381 }
382
383 #[test]
384 fn managed_provider_rejects_host_block() {
385 let def = StackDef::parse(
386 r#"
387[stack]
388name = "demo"
389[integrations.clerk]
390provider = "clerk"
391app_name = "demo"
392[integrations.clerk.render]
393credential_set = "development"
394[services.web]
395source = { repo = "https://example.invalid/web", ref = "main" }
396health = { path = "/" }
397[services.web.local]
398run = "true"
399"#,
400 )
401 .unwrap();
402 let err = validate_integration("clerk", &def.integrations["clerk"], Some("render"), KNOWN)
403 .unwrap_err();
404 assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
405 assert!(err.to_string().contains("managed"));
406 }
407
408 #[test]
409 fn global_only_managed_provider_rejects_local_host_block() {
410 let integration = Integration {
411 provider: "clerk".to_owned(),
412 fields: BTreeMap::from([
413 (
414 "app_name".to_owned(),
415 toml::Value::String("demo".to_owned()),
416 ),
417 (
418 "local".to_owned(),
419 toml::Value::Table(
420 [(
421 "app_name".to_owned(),
422 toml::Value::String("override".to_owned()),
423 )]
424 .into_iter()
425 .collect(),
426 ),
427 ),
428 ]),
429 };
430 let err = validate_integration("clerk", &integration, None, KNOWN).unwrap_err();
431 assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
432 }
433
434 #[test]
438 fn provider_rejects_blocked_setting() {
439 let def = StackDef::parse(
440 r#"
441[stack]
442name = "demo"
443[integrations.clerk]
444provider = "clerk"
445app_name = "demo"
446credential_set = "development"
447username = true
448[services.web]
449source = { repo = "https://example.invalid/web", ref = "main" }
450health = { path = "/" }
451[services.web.local]
452run = "true"
453"#,
454 )
455 .unwrap();
456 let err =
457 validate_integration("clerk", &def.integrations["clerk"], None, KNOWN).unwrap_err();
458 assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
459 assert!(err.to_string().contains("Sign-in with username"));
460 }
461}