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}