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/// ── `&mut self`, NOT `&self` ─────────────────────────────────────────
251/// Deliberate, and it constrains the shape downstream. The tonic clients
252/// behind `ProviderConn` need `&mut` per call, so a `&self` trait would
253/// force interior mutability on the ONE implementation that exists
254/// today, to buy sharing that no caller wants: each `LiveProvider` owns
255/// its connection exclusively. Callers therefore hold `Box<dyn Provider>`
256/// (owned), while the FACTORY is what gets shared. A native provider
257/// that happens to be stateless can simply ignore the `&mut`.
258///
259/// ── ORDERING IS A REAL PRECONDITION ──────────────────────────────────
260/// `configure` MUST run before any operation other than `get_schema`.
261/// The tfplugin protocol requires it, and providers on both SDKv2 and
262/// terraform-plugin-framework cache credentials there — some
263/// nil-dereference when called unconfigured. The trait cannot express
264/// this (it is a sequencing rule, not a type), so a native implementation
265/// must state what it does when called out of order rather than assume
266/// the engine's ordering holds. `dial_configured_provider` is the one
267/// place that establishes it.
268#[async_trait::async_trait]
269pub trait Provider: Send {
270    /// The provider's schema, reduced to implied cty types.
271    async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError>;
272
273    /// Provider credentials / settings. See the ordering note above.
274    async fn configure(
275        &mut self,
276        config: &DynamicValue,
277        terraform_version: &str,
278    ) -> Result<(), ProviderError>;
279
280    /// The provider's proposed new state PLUS its `requires_replace`
281    /// verdict — the only authoritative source for replace-vs-update.
282    async fn plan_resource_change(
283        &mut self,
284        type_name: &str,
285        prior_state: &DynamicValue,
286        proposed_new_state: &DynamicValue,
287        config: &DynamicValue,
288    ) -> Result<PlannedChange, ProviderError>;
289
290    /// Execute the change; returns the new state.
291    ///
292    /// A partial apply — the resource is committed but a follow-up call
293    /// failed — is `ProviderError::PartiallyApplied`, carrying the state.
294    /// An implementation that loses that state orphans real resources;
295    /// see that variant's doc for the measured EIP case.
296    async fn apply_resource_change(
297        &mut self,
298        type_name: &str,
299        prior_state: &DynamicValue,
300        planned_state: &DynamicValue,
301        config: &DynamicValue,
302    ) -> Result<DynamicValue, ProviderError>;
303
304    /// Refresh. `Ok(None)` means the resource no longer exists, so the
305    /// caller drops it from state — distinct from an error.
306    async fn read_resource(
307        &mut self,
308        type_name: &str,
309        current_state: &DynamicValue,
310    ) -> Result<Option<DynamicValue>, ProviderError>;
311
312    /// Read a data source, so `${data.<type>.<name>.<attr>}` resolves.
313    async fn read_data_source(
314        &mut self,
315        type_name: &str,
316        config: &DynamicValue,
317    ) -> Result<Option<DynamicValue>, ProviderError>;
318
319    /// Adopt an existing resource by id — the import half that powers
320    /// import-on-create-conflict and `magma import`.
321    async fn import_resource_state(
322        &mut self,
323        type_name: &str,
324        id: &str,
325    ) -> Result<Option<DynamicValue>, ProviderError>;
326
327    /// Migrate stored attribute JSON written under an older schema
328    /// version up to the current one.
329    async fn upgrade_resource_state(
330        &mut self,
331        type_name: &str,
332        stored_version: i64,
333        raw_json: &[u8],
334    ) -> Result<DynamicValue, ProviderError>;
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// A provider with no transport at all: no subprocess, no channel, no
342    /// Go. It exists to prove the contract is implementable without any of
343    /// them — which is the entire premise of this crate, and is otherwise
344    /// only an assertion in a doc comment.
345    struct TransportlessProvider;
346
347    #[async_trait::async_trait]
348    impl Provider for TransportlessProvider {
349        async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
350            Ok(ProviderSchema {
351                provider_config: CtyType::Object(BTreeMap::new()),
352                resources: BTreeMap::new(),
353                data_sources: BTreeMap::new(),
354                resource_versions: BTreeMap::new(),
355            })
356        }
357        async fn configure(&mut self, _: &DynamicValue, _: &str) -> Result<(), ProviderError> {
358            Ok(())
359        }
360        async fn plan_resource_change(
361            &mut self,
362            _: &str,
363            _: &DynamicValue,
364            _: &DynamicValue,
365            _: &DynamicValue,
366        ) -> Result<PlannedChange, ProviderError> {
367            Err(ProviderError::NoNewState)
368        }
369        async fn apply_resource_change(
370            &mut self,
371            _: &str,
372            _: &DynamicValue,
373            _: &DynamicValue,
374            _: &DynamicValue,
375        ) -> Result<DynamicValue, ProviderError> {
376            Err(ProviderError::NoNewState)
377        }
378        async fn read_resource(
379            &mut self,
380            _: &str,
381            _: &DynamicValue,
382        ) -> Result<Option<DynamicValue>, ProviderError> {
383            Ok(None)
384        }
385        async fn read_data_source(
386            &mut self,
387            _: &str,
388            _: &DynamicValue,
389        ) -> Result<Option<DynamicValue>, ProviderError> {
390            Ok(None)
391        }
392        async fn import_resource_state(
393            &mut self,
394            _: &str,
395            _: &str,
396        ) -> Result<Option<DynamicValue>, ProviderError> {
397            Ok(None)
398        }
399        async fn upgrade_resource_state(
400            &mut self,
401            _: &str,
402            _: i64,
403            _: &[u8],
404        ) -> Result<DynamicValue, ProviderError> {
405            Err(ProviderError::NoNewState)
406        }
407    }
408
409    /// ★ OBJECT SAFETY IS LOAD-BEARING, so it is pinned rather than assumed.
410    ///
411    /// The engine will hold `Box<dyn Provider>`, which requires the trait
412    /// to stay object-safe. Object safety is easy to lose by accident — one
413    /// generic method, one `where Self: Sized`, one `-> impl Trait` — and
414    /// the break surfaces at the CALL SITE in another crate, reported as a
415    /// confusing "cannot be made into an object" far from the edit that
416    /// caused it. Coercing here fails in THIS crate, next to the change.
417    #[tokio::test]
418    async fn the_contract_is_object_safe_and_transport_free() {
419        let mut p: Box<dyn Provider> = Box::new(TransportlessProvider);
420        let schema = p.get_schema().await.expect("stub schema");
421        assert!(schema.resources.is_empty());
422        // `Ok(None)` is "gone", NOT an error — a native implementation that
423        // conflates the two would make the engine drop live resources.
424        let empty = CtyType::Object(BTreeMap::new());
425        let dv = DynamicValue::from_json(&serde_json::json!({}), &empty).expect("empty object");
426        assert!(p.read_resource("x", &dv).await.expect("read").is_none());
427    }
428}