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 (steel::browser, SteelBrowser),
130 (supabase::project, SupabaseProject),
131 (supermemory::memory, SupermemoryMemory),
132 (tabstack::api, TabstackApi),
133 (turso::database, TursoDatabase),
134 (upstash::qstash, UpstashQstash),
135 (upstash::redis, UpstashRedis),
136 (upstash::search, UpstashSearch),
137 (upstash::vector, UpstashVector),
138 (wix::headless, WixHeadless),
139 (wordpress_com::site, WordPressComSite),
140 (workos::auth, WorkOSAuth),
141}
142
143fn lookup(provider: &str) -> Option<&'static ProviderEntry> {
144 PROVIDERS.iter().find(|entry| entry.provider == provider)
145}
146
147pub fn is_integration_resource(kind: &str) -> bool {
148 PROVIDERS.iter().any(|entry| entry.resource_kind == kind)
149}
150
151pub fn known_outputs(provider: &str) -> Option<&'static [&'static str]> {
152 lookup(provider).map(|entry| entry.outputs)
153}
154
155pub fn validate_integration(
156 name: &str,
157 integration: &Integration,
158 active_host: Option<&str>,
159 known_substrates: &[&str],
160) -> Result<(), IntegrationError> {
161 let entry = lookup(&integration.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
162 location: format!("integrations.{name}"),
163 detail: format!("unsupported provider {:?}", integration.provider),
164 })?;
165
166 validate_host_blocks(
167 name,
168 integration,
169 entry.hosting,
170 entry.config_scope,
171 known_substrates,
172 )?;
173
174 if let Some(host) = active_host
175 && matches!(entry.hosting, IntegrationHosting::HostBound(_))
176 && !host_bound_supports(entry.hosting, host)
177 {
178 return Err(IntegrationError::HostUnsupported {
179 provider: integration.provider.clone(),
180 host: host.to_owned(),
181 });
182 }
183
184 let config = match (entry.config_scope, active_host) {
185 (ConfigScope::PerHost, Some(host)) => integration.effective_config(host, known_substrates),
186 _ => integration.config_fields(known_substrates),
187 };
188 (entry.validate_config)(name, &config)?;
189
190 for setting in entry.blocked_settings {
191 if config_bool(&config, setting.key) {
192 return Err(IntegrationError::ConfigInvalid {
193 location: format!("integrations.{name}.{}", setting.key),
194 detail: setting.remediation.to_owned(),
195 });
196 }
197 }
198 Ok(())
199}
200
201pub fn validate_all(
202 def: &StackDef,
203 active_host: Option<&str>,
204 known_substrates: &[&str],
205) -> Result<(), IntegrationError> {
206 for (name, integration) in &def.integrations {
207 validate_integration(name, integration, active_host, known_substrates)?;
208 }
209 validate_integration_outputs(def, known_substrates)?;
210 Ok(())
211}
212
213fn validate_host_blocks(
214 name: &str,
215 integration: &Integration,
216 hosting: IntegrationHosting,
217 scope: ConfigScope,
218 known_substrates: &[&str],
219) -> Result<(), IntegrationError> {
220 for (host, _block) in integration.host_blocks(known_substrates) {
221 if matches!(hosting, IntegrationHosting::Managed) {
222 return Err(IntegrationError::ConfigInvalid {
223 location: format!("integrations.{name}.{host}"),
224 detail: format!(
225 "provider {:?} is managed and does not support per-host configuration",
226 integration.provider
227 ),
228 });
229 }
230 if matches!(scope, ConfigScope::GlobalOnly) {
231 return Err(IntegrationError::ConfigInvalid {
232 location: format!("integrations.{name}.{host}"),
233 detail: format!(
234 "provider {:?} does not support per-host configuration",
235 integration.provider
236 ),
237 });
238 }
239 if !host_bound_supports(hosting, &host) {
240 return Err(IntegrationError::ConfigInvalid {
241 location: format!("integrations.{name}.{host}"),
242 detail: format!(
243 "host {host:?} is not supported by provider {:?}",
244 integration.provider
245 ),
246 });
247 }
248 let _ = host_bound_hosts(hosting);
249 }
250 Ok(())
251}
252
253fn validate_integration_outputs(
254 def: &StackDef,
255 known_substrates: &[&str],
256) -> Result<(), IntegrationError> {
257 let mut locations = Vec::new();
258 if let Some(verify) = &def.stack.verify {
259 for (key, value) in &verify.env {
260 locations.push((format!("stack.verify.env.{key}"), value.clone()));
261 }
262 for (tier, spec) in &verify.tiers {
263 for (key, value) in &spec.env {
264 locations.push((
265 format!("stack.verify.tiers.{tier}.env.{key}"),
266 value.clone(),
267 ));
268 }
269 }
270 }
271 for (service_name, service) in &def.services {
272 for (key, value) in &service.env {
273 locations.push((format!("services.{service_name}.env.{key}"), value.clone()));
274 }
275 for &host in known_substrates {
276 for (key, value) in service.substrate_env(service_name, host).map_err(|err| {
277 IntegrationError::ConfigInvalid {
278 location: format!("services.{service_name}.{host}.env"),
279 detail: err.to_string(),
280 }
281 })? {
282 locations.push((format!("services.{service_name}.{host}.env.{key}"), value));
283 }
284 }
285 }
286 for (name, integration) in &def.integrations {
287 for (key, value) in integration.config_fields(known_substrates) {
288 if let Some(text) = value.as_str() {
289 locations.push((format!("integrations.{name}.{key}"), text.to_owned()));
290 }
291 }
292 }
293
294 for (location, value) in locations {
295 let refs = interp::references(&value, &location).map_err(|err| {
296 IntegrationError::ConfigInvalid {
297 location: location.clone(),
298 detail: err.to_string(),
299 }
300 })?;
301 for reference in refs {
302 let Reference::IntegrationOutput {
303 integration,
304 output,
305 } = reference
306 else {
307 continue;
308 };
309 let Some(spec) = def.integrations.get(&integration) else {
310 continue;
311 };
312 let outputs =
313 known_outputs(&spec.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
314 location: location.clone(),
315 detail: format!("integration {integration:?} has unsupported provider"),
316 })?;
317 if !outputs.contains(&output.as_str()) {
318 return Err(IntegrationError::ConfigInvalid {
319 location,
320 detail: format!(
321 "unknown output {output:?} for integration {integration:?} \
322 (known: {outputs:?})"
323 ),
324 });
325 }
326 }
327 }
328 Ok(())
329}
330
331pub fn dispatch_resource_kind(provider: &str) -> Option<&'static str> {
332 lookup(provider).map(|entry| entry.resource_kind)
333}
334
335pub fn ops_for(provider: &str) -> Option<&'static dyn ProviderOps> {
337 lookup(provider).map(|entry| entry.ops)
338}
339
340pub fn ops_for_resource_kind(kind: &str) -> Option<&'static dyn ProviderOps> {
342 PROVIDERS
343 .iter()
344 .find(|entry| entry.resource_kind == kind)
345 .map(|entry| entry.ops)
346}
347
348pub fn provider_host_keys(provider: &str) -> &'static [&'static str] {
353 lookup(provider)
354 .map(|entry| host_bound_hosts(entry.hosting))
355 .unwrap_or(&[])
356}
357
358#[cfg(test)]
359mod tests {
360 use stackless_core::def::StackDef;
361 use stackless_core::fault::{Fault, codes};
362
363 use super::*;
364
365 const KNOWN: &[&str] = &["local", "render", "vercel", "fly", "netlify"];
366
367 #[test]
370 fn registry_providers_and_resource_kinds_are_unique() {
371 use std::collections::BTreeSet;
372 let providers: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.provider).collect();
373 assert_eq!(
374 providers.len(),
375 PROVIDERS.len(),
376 "duplicate provider string"
377 );
378 let kinds: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.resource_kind).collect();
379 assert_eq!(kinds.len(), PROVIDERS.len(), "duplicate resource_kind");
380 }
381
382 #[test]
383 fn managed_provider_rejects_host_block() {
384 let def = StackDef::parse(
385 r#"
386[stack]
387name = "demo"
388[integrations.clerk]
389provider = "clerk"
390app_name = "demo"
391[integrations.clerk.render]
392credential_set = "development"
393[services.web]
394source = { repo = "https://example.invalid/web", ref = "main" }
395health = { path = "/" }
396[services.web.local]
397run = "true"
398"#,
399 )
400 .unwrap();
401 let err = validate_integration("clerk", &def.integrations["clerk"], Some("render"), KNOWN)
402 .unwrap_err();
403 assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
404 assert!(err.to_string().contains("managed"));
405 }
406
407 #[test]
408 fn global_only_managed_provider_rejects_local_host_block() {
409 let integration = Integration {
410 provider: "clerk".to_owned(),
411 fields: BTreeMap::from([
412 (
413 "app_name".to_owned(),
414 toml::Value::String("demo".to_owned()),
415 ),
416 (
417 "local".to_owned(),
418 toml::Value::Table(
419 [(
420 "app_name".to_owned(),
421 toml::Value::String("override".to_owned()),
422 )]
423 .into_iter()
424 .collect(),
425 ),
426 ),
427 ]),
428 };
429 let err = validate_integration("clerk", &integration, None, KNOWN).unwrap_err();
430 assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
431 }
432
433 #[test]
437 fn provider_rejects_blocked_setting() {
438 let def = StackDef::parse(
439 r#"
440[stack]
441name = "demo"
442[integrations.clerk]
443provider = "clerk"
444app_name = "demo"
445credential_set = "development"
446username = true
447[services.web]
448source = { repo = "https://example.invalid/web", ref = "main" }
449health = { path = "/" }
450[services.web.local]
451run = "true"
452"#,
453 )
454 .unwrap();
455 let err =
456 validate_integration("clerk", &def.integrations["clerk"], None, KNOWN).unwrap_err();
457 assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
458 assert!(err.to_string().contains("Sign-in with username"));
459 }
460}