stackless_provider_sdk/hostable.rs
1//! Integration provider metadata consumed by the registry for validation,
2//! output checking, and lifecycle dispatch. Each catalog adapter (Clerk, …)
3//! implements [`Hostable`] once; the registry is built only from those impls.
4
5/// Whether an integration's config may vary per stack host.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ConfigScope {
8 /// All settings live at `[integrations.<name>]`; host-key tables are rejected.
9 GlobalOnly,
10 /// `[integrations.<name>.<host>]` may override fields for hosts listed in
11 /// [`IntegrationHosting::HostBound`].
12 PerHost,
13}
14
15/// Where an integration's capability actually runs.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum IntegrationHosting {
18 /// Provider-operated cloud (e.g. Clerk). Not tied to `--on`; always available
19 /// when referenced. Must pair with [`ConfigScope::GlobalOnly`].
20 Managed,
21 /// Runs on or through specific stack hosts; `--on` must be in the host list.
22 HostBound(&'static [&'static str]),
23}
24
25/// A setting that cannot be applied with the credentials stackless holds and
26/// must be enabled out-of-band (Dashboard, vendor CLI, …). Declared per provider
27/// via [`Hostable::BLOCKED_SETTINGS`]; the registry fails validation with the
28/// remediation when a blocked key is set truthy, instead of silently ignoring
29/// it.
30#[derive(Debug, Clone, Copy)]
31pub struct BlockedSetting {
32 /// Config key under `[integrations.<name>]`.
33 pub key: &'static str,
34 /// Exact out-of-band remediation (Dashboard path, CLI command, …).
35 pub remediation: &'static str,
36}
37
38/// Metadata and compile-time constraints for one integration provider.
39///
40/// Implementors are registered in the integrations registry; validation and
41/// dispatch never use free-form provider strings outside this table.
42pub trait Hostable {
43 /// Catalog name in `stackless.toml` (`provider = "clerk"`).
44 const PROVIDER: &'static str;
45 /// Runtime placement model — see [`IntegrationHosting`].
46 const HOSTING: IntegrationHosting;
47 /// TOML override policy — see [`ConfigScope`].
48 const CONFIG_SCOPE: ConfigScope;
49 /// Checkpoint `resource_kind` written by the provisioner.
50 const RESOURCE_KIND: &'static str;
51 /// Outputs available in `${integrations.<name>.<output>}` references.
52 const OUTPUTS: &'static [&'static str];
53 /// Settings stackless cannot apply with the credentials it holds; setting one
54 /// truthy fails validation with its remediation. Default empty — most
55 /// providers have none.
56 const BLOCKED_SETTINGS: &'static [BlockedSetting] = &[];
57
58 /// Compile-time guard: managed providers cannot accept per-host config.
59 const VALIDATED: () = validate_hostable_pair(Self::HOSTING, Self::CONFIG_SCOPE);
60}
61
62/// Managed integrations must be globally configured.
63// Compile-time guard (evaluated via the `VALIDATED` associated const), so this
64// `panic!` is a build-time assertion that can never fire at runtime.
65#[allow(clippy::panic)]
66const fn validate_hostable_pair(hosting: IntegrationHosting, scope: ConfigScope) {
67 match (hosting, scope) {
68 (IntegrationHosting::Managed, ConfigScope::GlobalOnly) => (),
69 (IntegrationHosting::Managed, ConfigScope::PerHost) => {
70 panic!("Managed integrations must use ConfigScope::GlobalOnly")
71 }
72 _ => (),
73 }
74}
75
76/// Whether `host` is listed for a host-bound provider.
77pub fn host_bound_supports(hosting: IntegrationHosting, host: &str) -> bool {
78 match hosting {
79 IntegrationHosting::Managed => true,
80 IntegrationHosting::HostBound(hosts) => hosts.contains(&host),
81 }
82}
83
84/// Hosts declared for a host-bound provider (empty for managed). These are the
85/// substrate keys that count as host overrides for this provider's config.
86pub fn host_bound_hosts(hosting: IntegrationHosting) -> &'static [&'static str] {
87 match hosting {
88 IntegrationHosting::Managed => &[],
89 IntegrationHosting::HostBound(hosts) => hosts,
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn host_bound_supports_declared_hosts_only() {
99 let hosting = IntegrationHosting::HostBound(&["local", "render"]);
100 assert!(host_bound_supports(hosting, "local"));
101 assert!(host_bound_supports(hosting, "render"));
102 assert!(!host_bound_supports(hosting, "vercel"));
103 }
104
105 #[test]
106 fn managed_supports_every_active_host_check() {
107 let hosting = IntegrationHosting::Managed;
108 for host in ["local", "render", "vercel"] {
109 assert!(host_bound_supports(hosting, host));
110 }
111 }
112}