Skip to main content

magma_provider_api/
lib.rs

1//! The provider CONTRACT — what magma requires of *a provider*, with no
2//! commitment to how one is reached.
3//!
4//! ── ★ WHY THIS CRATE EXISTS ──────────────────────────────────────────
5//! Until now the contract was implicit: `magma-apply` held a concrete
6//! `magma_plugin::provider::ProviderConn` and called inherent methods on
7//! it. That bound the engine to ONE transport — a Go subprocess speaking
8//! tfplugin5/6 over go-plugin — and the binding was invisible, because a
9//! concrete type never announces that it is a choice.
10//!
11//! It is a choice, and an expensive one. Measured on the operator image
12//! (r349, trivy artifact 9648954592): the Rust binary scans **0**, while
13//! the 8 baked Go provider binaries carry **190 findings / 49 unique
14//! ids**. The largest single contributor is `terraform-provider-random`
15//! at 36 — a provider that makes no network calls at all.
16//!
17//! So the contract is named here, in a crate that depends on `magma-cty`
18//! and NOTHING ELSE. A provider is 8 methods over cty values. gRPC is one
19//! implementation of them; a native Rust provider is another, and needs
20//! no tonic, no subprocess, and no Go.
21//!
22//! ── WHAT LIVES HERE, AND WHY IT MOVED ────────────────────────────────
23//! `ProviderSchema`, `PlannedChange`, `Diag`, `Severity`, `ProviderError`
24//! and `SchemaError` were defined in `magma-plugin`. They are the
25//! protocol's DATA MODEL, not its transport, and leaving them next to the
26//! tonic client made the dependency edge point the wrong way: the trait
27//! crate would have had to depend on the gRPC crate that implements it —
28//! a cycle cargo rejects outright.
29//!
30//! They are moved verbatim, and `magma-plugin` re-exports every one of
31//! them, so every existing `magma_plugin::provider::ProviderError` path
32//! still resolves. This is a relocation, not a redesign.
33
34use std::collections::BTreeMap;
35
36use magma_cty::{CtyType, DynamicValue};
37
38#[derive(Debug, thiserror::Error)]
39pub enum SchemaError {
40    #[error("attribute {0:?} has neither a type nor a nested_type")]
41    AttributeNoType(String),
42    #[error("cty type decode for {0:?}: {1}")]
43    Cty(String, magma_cty::CtyError),
44    #[error("invalid nesting mode {0} for {1:?}")]
45    BadNesting(i32, String),
46    #[error("nested block {0:?} has no inner block")]
47    EmptyNestedBlock(String),
48}
49
50/// A provider's schema reduced to the implied cty types the apply codec
51/// needs: the provider-config type + each managed resource's type.
52#[derive(Debug, Clone)]
53pub struct ProviderSchema {
54    pub provider_config: CtyType,
55    pub resources: BTreeMap<String, CtyType>,
56    /// Data-source implied types (`data.<type>`), needed to encode a
57    /// ReadDataSource config + decode its result. Without these the apply
58    /// engine cannot evaluate `${data.*}` references (the rio-drive leak).
59    pub data_sources: BTreeMap<String, CtyType>,
60    /// Each managed resource type's CURRENT schema version, as declared by
61    /// the provider's `GetProviderSchema`/`GetSchema` response (the
62    /// `Schema.version` field sibling to the `Schema.block` that
63    /// `crate::schema::block_implied_type` turns into `resources`'
64    /// implied types). The terraform plugin protocol requires
65    /// `UpgradeResourceState` to run whenever a stored `StateInstance`'s
66    /// `schema_version` is older than this — otherwise its raw attribute
67    /// JSON (persisted under the OLD schema) gets decoded straight against
68    /// the NEW implied type, which a schema change can silently
69    /// misinterpret or fail to marshal. See
70    /// `ProviderConn::upgrade_resource_state` + `ProviderSchema::resource_version`.
71    pub resource_versions: BTreeMap<String, i64>,
72}
73
74impl ProviderSchema {
75    pub fn resource(&self, type_name: &str) -> Option<&CtyType> {
76        self.resources.get(type_name)
77    }
78
79    pub fn data_source(&self, type_name: &str) -> Option<&CtyType> {
80        self.data_sources.get(type_name)
81    }
82
83    /// The provider's CURRENT schema version for `type_name`. `0` both for
84    /// a genuinely version-0 schema AND for a type the provider never
85    /// declared — matching Terraform's own convention that an
86    /// un-versioned schema is version 0, so an unknown type never looks
87    /// artificially "newer" than a stored instance and never triggers a
88    /// spurious upgrade.
89    #[must_use]
90    pub fn resource_version(&self, type_name: &str) -> i64 {
91        self.resource_versions.get(type_name).copied().unwrap_or(0)
92    }
93
94    /// `Self::resource_version` clamped into `magma_types::StateInstance`'s
95    /// `u64` `schema_version` field. Real provider schema versions are
96    /// always small non-negative integers in practice; this only differs
97    /// from the wire `i64` for a malformed negative version (never
98    /// observed from a real provider), which clamps to `0` rather than
99    /// wrapping to a huge `u64`.
100    #[must_use]
101    pub fn resource_version_u64(&self, type_name: &str) -> u64 {
102        u64::try_from(self.resource_version(type_name)).unwrap_or(0)
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Diag {
108    pub severity: Severity,
109    pub summary: String,
110    pub detail: String,
111}
112
113/// The provider's full response to `PlanResourceChange`: the normalized
114/// planned state PLUS the attribute paths the provider says force a
115/// destroy+create instead of an in-place update ("requires replace").
116///
117/// Terraform core reads this exact signal — computed by the provider
118/// inside this SAME RPC — to decide Update vs Replace; it is not
119/// something the schema or config can determine on their own (a
120/// provider's `ForceNew` decision can be dynamic, e.g. via
121/// `CustomizeDiff`). Prior to this type existing, `ProviderConn::plan_resource_change`
122/// returned only the bare planned `DynamicValue`, silently discarding
123/// `requires_replace` — the ONE authoritative signal a provider gives
124/// for immutable/ForceNew attributes — before it ever reached
125/// magma-apply's business logic.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct PlannedChange {
128    pub state: DynamicValue,
129    /// Provider-reported attribute paths requiring replace, rendered as
130    /// dotted diagnostic strings (`"instance_types"`, `"tags.name"`,
131    /// `"rules[2].port"`). Empty ⇒ the provider says an in-place update
132    /// is sufficient. Diagnostic-only shape: callers only need to know
133    /// whether this is non-empty to trigger destroy+create orchestration
134    /// (see `magma-apply::engine::apply_one`); the strings make
135    /// `requires_replace` legible in logs/errors without threading a
136    /// full typed attribute-path AST through every consumer.
137    pub requires_replace: Vec<String>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Severity {
142    Error,
143    Warning,
144    Unknown,
145}
146
147#[derive(Debug, thiserror::Error)]
148pub enum ProviderError {
149    #[error("provider RPC transport: {0}")]
150    Transport(String),
151    #[error("provider returned {} error diagnostic(s): {}", .0.len(), fmt_diags(.0))]
152    Diagnostics(Vec<Diag>),
153    #[error("provider returned no new_state from apply")]
154    NoNewState,
155    /// The provider returned an error diagnostic AND a `new_state` — the
156    /// resource IS (at least partially) committed provider-side.
157    ///
158    /// This is not a rare edge: it is how the tfplugin contract expresses a
159    /// partial apply, and how Terraform core knows to persist a
160    /// half-created resource. AWS EIP is the canonical shape —
161    /// `AllocateAddress` commits, a follow-up call (tagging, association, the
162    /// post-create read) fails, and the provider returns the allocation id
163    /// together with an error.
164    ///
165    /// Before this variant existed, `check_diags(...)?` ran BEFORE
166    /// `new_state` was read, so that state was thrown away and the caller
167    /// recorded nothing. The next reconcile re-planned a CREATE and allocated
168    /// a SECOND resource. Measured 2026-08-01: two orphaned EIPs
169    /// (3.151.179.36, 18.227.192.150), each billable, neither in state, while
170    /// the run reported `created: 0`.
171    ///
172    /// Carrying the state in the ERROR — rather than returning `Ok` — keeps
173    /// the apply correctly failed while making the committed resource
174    /// impossible to drop on the floor: a caller must destructure this
175    /// variant to handle the error at all.
176    #[error("provider returned {} error diagnostic(s) WITH a new_state (partial apply — resource is committed): {}", .diags.len(), fmt_diags(.diags))]
177    PartiallyApplied {
178        diags: Vec<Diag>,
179        state: Box<DynamicValue>,
180    },
181    #[error("schema: {0}")]
182    Schema(#[from] SchemaError),
183}
184
185/// Is this provider error worth retrying with backoff? True for transient
186/// conditions — chiefly provider-side RATE LIMITING (github/cloud secondary
187/// rate limits surface as error diagnostics or transport errors) and
188/// transient transport faults. The tfplugin Diagnostic carries no status
189/// code, so detection is text-pattern matching on the diagnostic + transport
190/// strings. Permanent errors (bad config, schema, auth-denied) return false
191/// so they fail fast instead of looping.
192#[must_use]
193pub fn is_retryable(e: &ProviderError) -> bool {
194    const TRANSIENT: &[&str] = &[
195        "rate limit",
196        "secondary rate",
197        "too many request",
198        "abuse",
199        "quota",
200        "try again",
201        "retry",
202        "429",
203        "503",
204        "resource_exhausted",
205        "unavailable",
206        "timeout",
207        "timed out",
208        "connection reset",
209        "broken pipe",
210        "tls",
211        "transport",
212        "h2 protocol",
213        "eof",
214    ];
215    let hit = |s: &str| {
216        let l = s.to_ascii_lowercase();
217        TRANSIENT.iter().any(|p| l.contains(p))
218    };
219    match e {
220        ProviderError::Transport(s) => hit(s),
221        ProviderError::Diagnostics(diags) => {
222            diags.iter().any(|d| hit(&d.summary) || hit(&d.detail))
223        }
224        // NEVER retryable, whatever the diagnostic text says. The resource is
225        // already committed provider-side; re-issuing a create without an
226        // idempotency key allocates a SECOND one. Retrying a partial apply is
227        // strictly worse than failing it.
228        ProviderError::PartiallyApplied { .. } => false,
229        ProviderError::NoNewState | ProviderError::Schema(_) => false,
230    }
231}
232
233fn fmt_diags(diags: &[Diag]) -> String {
234    diags
235        .iter()
236        .map(|d| format!("{}: {}", d.summary, d.detail))
237        .collect::<Vec<_>>()
238        .join("; ")
239}
240
241/// A provider: the 8 operations magma performs against one, over
242/// `magma_cty` values.
243///
244/// ── THE WHOLE CONTRACT, AND IT IS SMALL ──────────────────────────────
245/// This is every call the apply engine makes. There is no ninth. That
246/// matters, because the surface being this small is what makes a native
247/// provider tractable at all — the cost of a provider is its API
248/// bindings, never its protocol.
249///
250/// ── `Send + Sync`, AND `Sync` IS NOT DECORATION ─────────────────────
251/// A `LiveProvider` is held across `.await` points inside futures that
252/// pangea-operator requires to be `Sync`, so a `Box<dyn Provider>` that
253/// is merely `Send` fails to compile at the CONSUMER — six E0277s in
254/// another repo, pointing at the operator's own functions rather than at
255/// this line. The concrete `ProviderConn` this replaced was `Sync`
256/// incidentally, so the bound was being satisfied by accident and
257/// erasing it to a trait object is what exposed the requirement.
258///
259/// ── `&mut self`, NOT `&self` ─────────────────────────────────────────
260/// Deliberate, and it constrains the shape downstream. The tonic clients
261/// behind `ProviderConn` need `&mut` per call, so a `&self` trait would
262/// force interior mutability on the ONE implementation that exists
263/// today, to buy sharing that no caller wants: each `LiveProvider` owns
264/// its connection exclusively. Callers therefore hold `Box<dyn Provider>`
265/// (owned), while the FACTORY is what gets shared. A native provider
266/// that happens to be stateless can simply ignore the `&mut`.
267///
268/// ── ORDERING IS A REAL PRECONDITION ──────────────────────────────────
269/// `configure` MUST run before any operation other than `get_schema`.
270/// The tfplugin protocol requires it, and providers on both SDKv2 and
271/// terraform-plugin-framework cache credentials there — some
272/// nil-dereference when called unconfigured. The trait cannot express
273/// this (it is a sequencing rule, not a type), so a native implementation
274/// must state what it does when called out of order rather than assume
275/// the engine's ordering holds. `dial_configured_provider` is the one
276/// place that establishes it.
277#[async_trait::async_trait]
278pub trait Provider: Send + Sync {
279    /// The provider's schema, reduced to implied cty types.
280    async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError>;
281
282    /// Provider credentials / settings. See the ordering note above.
283    async fn configure(
284        &mut self,
285        config: &DynamicValue,
286        terraform_version: &str,
287    ) -> Result<(), ProviderError>;
288
289    /// The provider's proposed new state PLUS its `requires_replace`
290    /// verdict — the only authoritative source for replace-vs-update.
291    async fn plan_resource_change(
292        &mut self,
293        type_name: &str,
294        prior_state: &DynamicValue,
295        proposed_new_state: &DynamicValue,
296        config: &DynamicValue,
297    ) -> Result<PlannedChange, ProviderError>;
298
299    /// Execute the change; returns the new state.
300    ///
301    /// A partial apply — the resource is committed but a follow-up call
302    /// failed — is `ProviderError::PartiallyApplied`, carrying the state.
303    /// An implementation that loses that state orphans real resources;
304    /// see that variant's doc for the measured EIP case.
305    async fn apply_resource_change(
306        &mut self,
307        type_name: &str,
308        prior_state: &DynamicValue,
309        planned_state: &DynamicValue,
310        config: &DynamicValue,
311    ) -> Result<DynamicValue, ProviderError>;
312
313    /// Refresh. `Ok(None)` means the resource no longer exists, so the
314    /// caller drops it from state — distinct from an error.
315    async fn read_resource(
316        &mut self,
317        type_name: &str,
318        current_state: &DynamicValue,
319    ) -> Result<Option<DynamicValue>, ProviderError>;
320
321    /// Read a data source, so `${data.<type>.<name>.<attr>}` resolves.
322    async fn read_data_source(
323        &mut self,
324        type_name: &str,
325        config: &DynamicValue,
326    ) -> Result<Option<DynamicValue>, ProviderError>;
327
328    /// Adopt an existing resource by id — the import half that powers
329    /// import-on-create-conflict and `magma import`.
330    async fn import_resource_state(
331        &mut self,
332        type_name: &str,
333        id: &str,
334    ) -> Result<Option<DynamicValue>, ProviderError>;
335
336    /// Migrate stored attribute JSON written under an older schema
337    /// version up to the current one.
338    async fn upgrade_resource_state(
339        &mut self,
340        type_name: &str,
341        stored_version: i64,
342        raw_json: &[u8],
343    ) -> Result<DynamicValue, ProviderError>;
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    /// A provider with no transport at all: no subprocess, no channel, no
351    /// Go. It exists to prove the contract is implementable without any of
352    /// them — which is the entire premise of this crate, and is otherwise
353    /// only an assertion in a doc comment.
354    struct TransportlessProvider;
355
356    #[async_trait::async_trait]
357    impl Provider for TransportlessProvider {
358        async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
359            Ok(ProviderSchema {
360                provider_config: CtyType::Object(BTreeMap::new()),
361                resources: BTreeMap::new(),
362                data_sources: BTreeMap::new(),
363                resource_versions: BTreeMap::new(),
364            })
365        }
366        async fn configure(&mut self, _: &DynamicValue, _: &str) -> Result<(), ProviderError> {
367            Ok(())
368        }
369        async fn plan_resource_change(
370            &mut self,
371            _: &str,
372            _: &DynamicValue,
373            _: &DynamicValue,
374            _: &DynamicValue,
375        ) -> Result<PlannedChange, ProviderError> {
376            Err(ProviderError::NoNewState)
377        }
378        async fn apply_resource_change(
379            &mut self,
380            _: &str,
381            _: &DynamicValue,
382            _: &DynamicValue,
383            _: &DynamicValue,
384        ) -> Result<DynamicValue, ProviderError> {
385            Err(ProviderError::NoNewState)
386        }
387        async fn read_resource(
388            &mut self,
389            _: &str,
390            _: &DynamicValue,
391        ) -> Result<Option<DynamicValue>, ProviderError> {
392            Ok(None)
393        }
394        async fn read_data_source(
395            &mut self,
396            _: &str,
397            _: &DynamicValue,
398        ) -> Result<Option<DynamicValue>, ProviderError> {
399            Ok(None)
400        }
401        async fn import_resource_state(
402            &mut self,
403            _: &str,
404            _: &str,
405        ) -> Result<Option<DynamicValue>, ProviderError> {
406            Ok(None)
407        }
408        async fn upgrade_resource_state(
409            &mut self,
410            _: &str,
411            _: i64,
412            _: &[u8],
413        ) -> Result<DynamicValue, ProviderError> {
414            Err(ProviderError::NoNewState)
415        }
416    }
417
418    /// ★ THE `Sync` BOUND IS PINNED HERE, because losing it fails
419    /// SOMEWHERE ELSE.
420    ///
421    /// `LiveProvider` is held across `.await` in futures pangea-operator
422    /// requires to be `Sync`. Weaken this bound and this crate still
423    /// compiles, magma still compiles, every magma test still passes — and
424    /// the operator fails with six E0277s pointing at ITS functions, in
425    /// another repository, on a pin bump. Measured exactly that way: the
426    /// bound started as `Send` alone and that is how it surfaced.
427    #[test]
428    fn the_contract_is_send_and_sync() {
429        const fn require<T: Send + Sync + ?Sized>() {}
430        require::<dyn Provider>();
431    }
432
433    /// ★ OBJECT SAFETY IS LOAD-BEARING, so it is pinned rather than assumed.
434    ///
435    /// The engine will hold `Box<dyn Provider>`, which requires the trait
436    /// to stay object-safe. Object safety is easy to lose by accident — one
437    /// generic method, one `where Self: Sized`, one `-> impl Trait` — and
438    /// the break surfaces at the CALL SITE in another crate, reported as a
439    /// confusing "cannot be made into an object" far from the edit that
440    /// caused it. Coercing here fails in THIS crate, next to the change.
441    #[tokio::test]
442    async fn the_contract_is_object_safe_and_transport_free() {
443        let mut p: Box<dyn Provider> = Box::new(TransportlessProvider);
444        let schema = p.get_schema().await.expect("stub schema");
445        assert!(schema.resources.is_empty());
446        // `Ok(None)` is "gone", NOT an error — a native implementation that
447        // conflates the two would make the engine drop live resources.
448        let empty = CtyType::Object(BTreeMap::new());
449        let dv = DynamicValue::from_json(&serde_json::json!({}), &empty).expect("empty object");
450        assert!(p.read_resource("x", &dv).await.expect("read").is_none());
451    }
452}