Skip to main content

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}