Skip to main content

magma_plugin/
provider.rs

1//! Typed provider-RPC wrappers — the tfplugin5/6 `Provider` service over
2//! a dialed [`Channel`], speaking [`magma_cty`] values.
3//!
4//! This is the layer that lets `magma-apply` stop being a structural
5//! no-op and actually create resources (magma#2). [`Plugin::dial`] hands
6//! back a gRPC `Channel` + the negotiated protocol; [`ProviderConn`]
7//! wraps the matching generated client and exposes the four RPCs the
8//! apply engine needs:
9//!
10//! - [`ProviderConn::get_schema`] — resource type → implied [`CtyType`].
11//! - [`ProviderConn::configure`] — provider credentials / settings.
12//! - [`ProviderConn::plan_resource_change`] — provider-proposed new state.
13//! - [`ProviderConn::apply_resource_change`] — create/update/delete; the
14//!   returned `DynamicValue` is the resource's new state.
15//!
16//! **Both protocols are dispatched.** SDKv2 providers (github — galho's
17//! target — aws, …) speak tfplugin5; framework providers speak tfplugin6.
18//! `ProviderConn::new` selects the client from the handshake's negotiated
19//! `PluginProtocol`. The schema parser is shared (v5 schemas convert to
20//! v6 via [`crate::schema::block5_implied_type`]); error-severity
21//! diagnostics (severity `1` in both protocols) become a typed
22//! [`ProviderError`].
23
24use std::collections::BTreeMap;
25
26use magma_cty::{CtyType, DynamicValue};
27use magma_protocol::{PluginProtocol, tfplugin5, tfplugin6};
28
29use crate::H2Channel;
30use crate::schema::{self, SchemaError};
31
32type Client5 = tfplugin5::provider_client::ProviderClient<H2Channel>;
33type Client6 = tfplugin6::provider_client::ProviderClient<H2Channel>;
34
35enum Client {
36    V5(Client5),
37    V6(Client6),
38}
39
40/// The client capabilities magma announces on every protocol request that
41/// carries a `ClientCapabilities` field (`ConfigureProvider`, `ReadResource`,
42/// `PlanResourceChange`, `ImportResourceState`, `ReadDataSource`, …).
43///
44/// Modern providers built on terraform-plugin-framework v1.15+ read this
45/// field; an ABSENT (`None`) `ClientCapabilities` drives some framework
46/// data-source / resource paths into a nil dereference — the provider logs
47/// "No announced client capabilities" then SIGSEGVs (observed live: cloudflare
48/// 5.13.0 nil-deref in `server_readdatasource.go` on `cloudflare_accounts`).
49/// We announce explicit capabilities so the field is always PRESENT. magma
50/// does not yet implement provider response *deferral* or *write-only*
51/// attributes, so both are `false` — present-and-false, never absent.
52pub(crate) fn client_caps_v6() -> Option<tfplugin6::ClientCapabilities> {
53    Some(tfplugin6::ClientCapabilities {
54        deferral_allowed: false,
55        write_only_attributes_allowed: false,
56    })
57}
58
59fn client_caps_v5() -> Option<tfplugin5::ClientCapabilities> {
60    Some(tfplugin5::ClientCapabilities {
61        deferral_allowed: false,
62        write_only_attributes_allowed: false,
63    })
64}
65
66/// A connected provider — the protocol-matched client over a dialed channel.
67pub struct ProviderConn {
68    client: Client,
69}
70
71/// A provider's schema reduced to the implied cty types the apply codec
72/// needs: the provider-config type + each managed resource's type.
73#[derive(Debug, Clone)]
74pub struct ProviderSchema {
75    pub provider_config: CtyType,
76    pub resources: BTreeMap<String, CtyType>,
77    /// Data-source implied types (`data.<type>`), needed to encode a
78    /// ReadDataSource config + decode its result. Without these the apply
79    /// engine cannot evaluate `${data.*}` references (the rio-drive leak).
80    pub data_sources: BTreeMap<String, CtyType>,
81    /// Each managed resource type's CURRENT schema version, as declared by
82    /// the provider's `GetProviderSchema`/`GetSchema` response (the
83    /// `Schema.version` field sibling to the `Schema.block` that
84    /// [`crate::schema::block_implied_type`] turns into `resources`'
85    /// implied types). The terraform plugin protocol requires
86    /// `UpgradeResourceState` to run whenever a stored `StateInstance`'s
87    /// `schema_version` is older than this — otherwise its raw attribute
88    /// JSON (persisted under the OLD schema) gets decoded straight against
89    /// the NEW implied type, which a schema change can silently
90    /// misinterpret or fail to marshal. See
91    /// [`ProviderConn::upgrade_resource_state`] + [`ProviderSchema::resource_version`].
92    pub resource_versions: BTreeMap<String, i64>,
93}
94
95impl ProviderSchema {
96    pub fn resource(&self, type_name: &str) -> Option<&CtyType> {
97        self.resources.get(type_name)
98    }
99
100    pub fn data_source(&self, type_name: &str) -> Option<&CtyType> {
101        self.data_sources.get(type_name)
102    }
103
104    /// The provider's CURRENT schema version for `type_name`. `0` both for
105    /// a genuinely version-0 schema AND for a type the provider never
106    /// declared — matching Terraform's own convention that an
107    /// un-versioned schema is version 0, so an unknown type never looks
108    /// artificially "newer" than a stored instance and never triggers a
109    /// spurious upgrade.
110    #[must_use]
111    pub fn resource_version(&self, type_name: &str) -> i64 {
112        self.resource_versions.get(type_name).copied().unwrap_or(0)
113    }
114
115    /// [`Self::resource_version`] clamped into `magma_types::StateInstance`'s
116    /// `u64` `schema_version` field. Real provider schema versions are
117    /// always small non-negative integers in practice; this only differs
118    /// from the wire `i64` for a malformed negative version (never
119    /// observed from a real provider), which clamps to `0` rather than
120    /// wrapping to a huge `u64`.
121    #[must_use]
122    pub fn resource_version_u64(&self, type_name: &str) -> u64 {
123        u64::try_from(self.resource_version(type_name)).unwrap_or(0)
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Diag {
129    pub severity: Severity,
130    pub summary: String,
131    pub detail: String,
132}
133
134/// The provider's full response to `PlanResourceChange`: the normalized
135/// planned state PLUS the attribute paths the provider says force a
136/// destroy+create instead of an in-place update ("requires replace").
137///
138/// Terraform core reads this exact signal — computed by the provider
139/// inside this SAME RPC — to decide Update vs Replace; it is not
140/// something the schema or config can determine on their own (a
141/// provider's `ForceNew` decision can be dynamic, e.g. via
142/// `CustomizeDiff`). Prior to this type existing, [`ProviderConn::plan_resource_change`]
143/// returned only the bare planned [`DynamicValue`], silently discarding
144/// `requires_replace` — the ONE authoritative signal a provider gives
145/// for immutable/ForceNew attributes — before it ever reached
146/// magma-apply's business logic.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct PlannedChange {
149    pub state: DynamicValue,
150    /// Provider-reported attribute paths requiring replace, rendered as
151    /// dotted diagnostic strings (`"instance_types"`, `"tags.name"`,
152    /// `"rules[2].port"`). Empty ⇒ the provider says an in-place update
153    /// is sufficient. Diagnostic-only shape: callers only need to know
154    /// whether this is non-empty to trigger destroy+create orchestration
155    /// (see `magma-apply::engine::apply_one`); the strings make
156    /// `requires_replace` legible in logs/errors without threading a
157    /// full typed attribute-path AST through every consumer.
158    pub requires_replace: Vec<String>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum Severity {
163    Error,
164    Warning,
165    Unknown,
166}
167
168#[derive(Debug, thiserror::Error)]
169pub enum ProviderError {
170    #[error("provider RPC transport: {0}")]
171    Transport(String),
172    #[error("provider returned {} error diagnostic(s): {}", .0.len(), fmt_diags(.0))]
173    Diagnostics(Vec<Diag>),
174    #[error("provider returned no new_state from apply")]
175    NoNewState,
176    /// The provider returned an error diagnostic AND a `new_state` — the
177    /// resource IS (at least partially) committed provider-side.
178    ///
179    /// This is not a rare edge: it is how the tfplugin contract expresses a
180    /// partial apply, and how Terraform core knows to persist a
181    /// half-created resource. AWS EIP is the canonical shape —
182    /// `AllocateAddress` commits, a follow-up call (tagging, association, the
183    /// post-create read) fails, and the provider returns the allocation id
184    /// together with an error.
185    ///
186    /// Before this variant existed, `check_diags(...)?` ran BEFORE
187    /// `new_state` was read, so that state was thrown away and the caller
188    /// recorded nothing. The next reconcile re-planned a CREATE and allocated
189    /// a SECOND resource. Measured 2026-08-01: two orphaned EIPs
190    /// (3.151.179.36, 18.227.192.150), each billable, neither in state, while
191    /// the run reported `created: 0`.
192    ///
193    /// Carrying the state in the ERROR — rather than returning `Ok` — keeps
194    /// the apply correctly failed while making the committed resource
195    /// impossible to drop on the floor: a caller must destructure this
196    /// variant to handle the error at all.
197    #[error("provider returned {} error diagnostic(s) WITH a new_state (partial apply — resource is committed): {}", .diags.len(), fmt_diags(.diags))]
198    PartiallyApplied {
199        diags: Vec<Diag>,
200        state: Box<DynamicValue>,
201    },
202    #[error("schema: {0}")]
203    Schema(#[from] SchemaError),
204}
205
206/// Is this provider error worth retrying with backoff? True for transient
207/// conditions — chiefly provider-side RATE LIMITING (github/cloud secondary
208/// rate limits surface as error diagnostics or transport errors) and
209/// transient transport faults. The tfplugin Diagnostic carries no status
210/// code, so detection is text-pattern matching on the diagnostic + transport
211/// strings. Permanent errors (bad config, schema, auth-denied) return false
212/// so they fail fast instead of looping.
213#[must_use]
214pub fn is_retryable(e: &ProviderError) -> bool {
215    const TRANSIENT: &[&str] = &[
216        "rate limit",
217        "secondary rate",
218        "too many request",
219        "abuse",
220        "quota",
221        "try again",
222        "retry",
223        "429",
224        "503",
225        "resource_exhausted",
226        "unavailable",
227        "timeout",
228        "timed out",
229        "connection reset",
230        "broken pipe",
231        "tls",
232        "transport",
233        "h2 protocol",
234        "eof",
235    ];
236    let hit = |s: &str| {
237        let l = s.to_ascii_lowercase();
238        TRANSIENT.iter().any(|p| l.contains(p))
239    };
240    match e {
241        ProviderError::Transport(s) => hit(s),
242        ProviderError::Diagnostics(diags) => {
243            diags.iter().any(|d| hit(&d.summary) || hit(&d.detail))
244        }
245        // NEVER retryable, whatever the diagnostic text says. The resource is
246        // already committed provider-side; re-issuing a create without an
247        // idempotency key allocates a SECOND one. Retrying a partial apply is
248        // strictly worse than failing it.
249        ProviderError::PartiallyApplied { .. } => false,
250        ProviderError::NoNewState | ProviderError::Schema(_) => false,
251    }
252}
253
254fn fmt_diags(diags: &[Diag]) -> String {
255    diags
256        .iter()
257        .map(|d| format!("{}: {}", d.summary, d.detail))
258        .collect::<Vec<_>>()
259        .join("; ")
260}
261
262impl ProviderConn {
263    /// Wrap a dialed channel, selecting the client by the handshake's
264    /// negotiated protocol (`Plugin::handshake().app_protocol`).
265    pub fn new(channel: H2Channel, protocol: PluginProtocol) -> Self {
266        let client = match protocol {
267            PluginProtocol::V5 => {
268                Client::V5(Client5::new(channel).max_decoding_message_size(256 * 1024 * 1024))
269            }
270            PluginProtocol::V6 => {
271                Client::V6(Client6::new(channel).max_decoding_message_size(256 * 1024 * 1024))
272            }
273        };
274        Self { client }
275    }
276
277    /// `GetProviderSchema` (v6) / `GetSchema` (v5) → provider-config +
278    /// per-resource implied types.
279    pub async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
280        match &mut self.client {
281            Client::V6(c) => {
282                let resp = c
283                    .get_provider_schema(tfplugin6::get_provider_schema::Request::default())
284                    .await
285                    .map_err(transport)?
286                    .into_inner();
287                check_diags(resp.diagnostics.iter().map(diag6))?;
288                let provider_config = match resp.provider.and_then(|s| s.block) {
289                    Some(b) => schema::block_implied_type(&b)?,
290                    None => CtyType::Object(BTreeMap::new()),
291                };
292                let mut resources = BTreeMap::new();
293                let mut resource_versions = BTreeMap::new();
294                for (name, sch) in resp.resource_schemas {
295                    // Capture the schema version regardless of whether the
296                    // block parses, so a resource_version() lookup is never
297                    // silently missing for a type whose implied type failed
298                    // to decode.
299                    resource_versions.insert(name.clone(), sch.version);
300                    if let Some(b) = sch.block {
301                        resources.insert(name, schema::block_implied_type(&b)?);
302                    }
303                }
304                let mut data_sources = BTreeMap::new();
305                for (name, sch) in resp.data_source_schemas {
306                    if let Some(b) = sch.block {
307                        data_sources.insert(name, schema::block_implied_type(&b)?);
308                    }
309                }
310                Ok(ProviderSchema {
311                    provider_config,
312                    resources,
313                    data_sources,
314                    resource_versions,
315                })
316            }
317            Client::V5(c) => {
318                let resp = c
319                    .get_schema(tfplugin5::get_provider_schema::Request::default())
320                    .await
321                    .map_err(transport)?
322                    .into_inner();
323                check_diags(resp.diagnostics.iter().map(diag5))?;
324                let provider_config = match resp.provider.and_then(|s| s.block) {
325                    Some(b) => schema::block5_implied_type(&b)?,
326                    None => CtyType::Object(BTreeMap::new()),
327                };
328                let mut resources = BTreeMap::new();
329                let mut resource_versions = BTreeMap::new();
330                for (name, sch) in resp.resource_schemas {
331                    resource_versions.insert(name.clone(), sch.version);
332                    if let Some(b) = sch.block {
333                        resources.insert(name, schema::block5_implied_type(&b)?);
334                    }
335                }
336                let mut data_sources = BTreeMap::new();
337                for (name, sch) in resp.data_source_schemas {
338                    if let Some(b) = sch.block {
339                        data_sources.insert(name, schema::block5_implied_type(&b)?);
340                    }
341                }
342                Ok(ProviderSchema {
343                    provider_config,
344                    resources,
345                    data_sources,
346                    resource_versions,
347                })
348            }
349        }
350    }
351
352    /// `ConfigureProvider` (v6) / `Configure` (v5) — provider creds/settings.
353    pub async fn configure(
354        &mut self,
355        config: &DynamicValue,
356        terraform_version: &str,
357    ) -> Result<(), ProviderError> {
358        match &mut self.client {
359            Client::V6(c) => {
360                let resp = c
361                    .configure_provider(tfplugin6::configure_provider::Request {
362                        terraform_version: terraform_version.to_string(),
363                        config: Some(to_pb6(config)),
364                        client_capabilities: client_caps_v6(),
365                        ..Default::default()
366                    })
367                    .await
368                    .map_err(transport)?
369                    .into_inner();
370                check_diags(resp.diagnostics.iter().map(diag6))
371            }
372            Client::V5(c) => {
373                let resp = c
374                    .configure(tfplugin5::configure::Request {
375                        terraform_version: terraform_version.to_string(),
376                        config: Some(to_pb5(config)),
377                        client_capabilities: client_caps_v5(),
378                        ..Default::default()
379                    })
380                    .await
381                    .map_err(transport)?
382                    .into_inner();
383                check_diags(resp.diagnostics.iter().map(diag5))
384            }
385        }
386    }
387
388    /// `PlanResourceChange` — the provider's proposed new state PLUS
389    /// which attribute paths (if any) force a destroy+create instead of
390    /// an in-place update. See [`PlannedChange`]'s doc for why the
391    /// latter matters: it is the ONLY authoritative source for that
392    /// decision, and was silently discarded here before `PlannedChange`
393    /// existed.
394    pub async fn plan_resource_change(
395        &mut self,
396        type_name: &str,
397        prior_state: &DynamicValue,
398        proposed_new_state: &DynamicValue,
399        config: &DynamicValue,
400    ) -> Result<PlannedChange, ProviderError> {
401        match &mut self.client {
402            Client::V6(c) => {
403                let resp = c
404                    .plan_resource_change(tfplugin6::plan_resource_change::Request {
405                        type_name: type_name.to_string(),
406                        prior_state: Some(to_pb6(prior_state)),
407                        proposed_new_state: Some(to_pb6(proposed_new_state)),
408                        config: Some(to_pb6(config)),
409                        client_capabilities: client_caps_v6(),
410                        ..Default::default()
411                    })
412                    .await
413                    .map_err(transport)?
414                    .into_inner();
415                check_diags(resp.diagnostics.iter().map(diag6))?;
416                let requires_replace = resp
417                    .requires_replace
418                    .iter()
419                    .map(attribute_path_to_string_v6)
420                    .collect();
421                let state = resp
422                    .planned_state
423                    .map(from_pb6)
424                    .ok_or(ProviderError::NoNewState)?;
425                Ok(PlannedChange {
426                    state,
427                    requires_replace,
428                })
429            }
430            Client::V5(c) => {
431                let resp = c
432                    .plan_resource_change(tfplugin5::plan_resource_change::Request {
433                        type_name: type_name.to_string(),
434                        prior_state: Some(to_pb5(prior_state)),
435                        proposed_new_state: Some(to_pb5(proposed_new_state)),
436                        config: Some(to_pb5(config)),
437                        client_capabilities: client_caps_v5(),
438                        ..Default::default()
439                    })
440                    .await
441                    .map_err(transport)?
442                    .into_inner();
443                check_diags(resp.diagnostics.iter().map(diag5))?;
444                let requires_replace = resp
445                    .requires_replace
446                    .iter()
447                    .map(attribute_path_to_string_v5)
448                    .collect();
449                let state = resp
450                    .planned_state
451                    .map(from_pb5)
452                    .ok_or(ProviderError::NoNewState)?;
453                Ok(PlannedChange {
454                    state,
455                    requires_replace,
456                })
457            }
458        }
459    }
460
461    /// `ApplyResourceChange` — execute the change. Returns the new state.
462    pub async fn apply_resource_change(
463        &mut self,
464        type_name: &str,
465        prior_state: &DynamicValue,
466        planned_state: &DynamicValue,
467        config: &DynamicValue,
468    ) -> Result<DynamicValue, ProviderError> {
469        match &mut self.client {
470            Client::V6(c) => {
471                let resp = c
472                    .apply_resource_change(tfplugin6::apply_resource_change::Request {
473                        type_name: type_name.to_string(),
474                        prior_state: Some(to_pb6(prior_state)),
475                        planned_state: Some(to_pb6(planned_state)),
476                        config: Some(to_pb6(config)),
477                        ..Default::default()
478                    })
479                    .await
480                    .map_err(transport)?
481                    .into_inner();
482                // Read `new_state` BEFORE deciding on diagnostics. The old order
483                // was `check_diags(...)?` first, which discarded a committed
484                // resource whenever the provider reported an error alongside it
485                // — the partial-apply leak (see ProviderError::PartiallyApplied).
486                apply_outcome(
487                    error_diags(resp.diagnostics.iter().map(diag6)),
488                    resp.new_state.map(from_pb6),
489                )
490            }
491            Client::V5(c) => {
492                let resp = c
493                    .apply_resource_change(tfplugin5::apply_resource_change::Request {
494                        type_name: type_name.to_string(),
495                        prior_state: Some(to_pb5(prior_state)),
496                        planned_state: Some(to_pb5(planned_state)),
497                        config: Some(to_pb5(config)),
498                        ..Default::default()
499                    })
500                    .await
501                    .map_err(transport)?
502                    .into_inner();
503                // Read `new_state` BEFORE deciding on diagnostics. The old order
504                // was `check_diags(...)?` first, which discarded a committed
505                // resource whenever the provider reported an error alongside it
506                // — the partial-apply leak (see ProviderError::PartiallyApplied).
507                apply_outcome(
508                    error_diags(resp.diagnostics.iter().map(diag5)),
509                    resp.new_state.map(from_pb5),
510                )
511            }
512        }
513    }
514
515    /// `ReadResource` — read the resource's ACTUAL current state from the
516    /// provider (the refresh primitive). Returns `Ok(None)` when the provider
517    /// reports the resource no longer exists (`new_state` is cty-null), so
518    /// callers drop stale / phantom entries from state; `Ok(Some(dv))` with
519    /// the refreshed wire state when it still exists.
520    pub async fn read_resource(
521        &mut self,
522        type_name: &str,
523        current_state: &DynamicValue,
524    ) -> Result<Option<DynamicValue>, ProviderError> {
525        match &mut self.client {
526            Client::V6(c) => {
527                let resp = c
528                    .read_resource(tfplugin6::read_resource::Request {
529                        type_name: type_name.to_string(),
530                        current_state: Some(to_pb6(current_state)),
531                        client_capabilities: client_caps_v6(),
532                        ..Default::default()
533                    })
534                    .await
535                    .map_err(transport)?
536                    .into_inner();
537                check_diags(resp.diagnostics.iter().map(diag6))?;
538                Ok(resp.new_state.map(from_pb6).filter(|d| !d.is_null()))
539            }
540            Client::V5(c) => {
541                let resp = c
542                    .read_resource(tfplugin5::read_resource::Request {
543                        type_name: type_name.to_string(),
544                        current_state: Some(to_pb5(current_state)),
545                        client_capabilities: client_caps_v5(),
546                        ..Default::default()
547                    })
548                    .await
549                    .map_err(transport)?
550                    .into_inner();
551                check_diags(resp.diagnostics.iter().map(diag5))?;
552                Ok(resp.new_state.map(from_pb5).filter(|d| !d.is_null()))
553            }
554        }
555    }
556
557    /// `ReadDataSource` — evaluate a `data` block by querying the provider,
558    /// returning its result state. The apply engine reads data sources up front
559    /// so `${data.<type>.<name>.<attr>}` references resolve; without it those
560    /// strings leaked verbatim to managed-resource RPCs (the rio-drive
561    /// Cloudflare 400). `Ok(None)` if the provider returned cty-null.
562    pub async fn read_data_source(
563        &mut self,
564        type_name: &str,
565        config: &DynamicValue,
566    ) -> Result<Option<DynamicValue>, ProviderError> {
567        match &mut self.client {
568            Client::V6(c) => {
569                let resp = c
570                    .read_data_source(tfplugin6::read_data_source::Request {
571                        type_name: type_name.to_string(),
572                        config: Some(to_pb6(config)),
573                        client_capabilities: client_caps_v6(),
574                        ..Default::default()
575                    })
576                    .await
577                    .map_err(transport)?
578                    .into_inner();
579                check_diags(resp.diagnostics.iter().map(diag6))?;
580                Ok(resp.state.map(from_pb6).filter(|d| !d.is_null()))
581            }
582            Client::V5(c) => {
583                let resp = c
584                    .read_data_source(tfplugin5::read_data_source::Request {
585                        type_name: type_name.to_string(),
586                        config: Some(to_pb5(config)),
587                        client_capabilities: client_caps_v5(),
588                        ..Default::default()
589                    })
590                    .await
591                    .map_err(transport)?
592                    .into_inner();
593                check_diags(resp.diagnostics.iter().map(diag5))?;
594                Ok(resp.state.map(from_pb5).filter(|d| !d.is_null()))
595            }
596        }
597    }
598
599    /// `ImportResourceState` — adopt a resource that EXISTS in the cloud but
600    /// is absent from magma's state, by its provider-native import id (e.g.
601    /// the repo name for `github_repository`). Returns the imported wire state
602    /// (`Ok(Some(dv))`) or `Ok(None)` if the provider imported nothing /
603    /// returned cty-null. This is the read/import half of the protocol that
604    /// powers import-on-create-conflict (422 already-exists → observe → adopt)
605    /// + `magma import`. Mirrors apply/read's V5/V6 dispatch + msgpack decode.
606    pub async fn import_resource_state(
607        &mut self,
608        type_name: &str,
609        id: &str,
610    ) -> Result<Option<DynamicValue>, ProviderError> {
611        match &mut self.client {
612            Client::V6(c) => {
613                let resp = c
614                    .import_resource_state(tfplugin6::import_resource_state::Request {
615                        type_name: type_name.to_string(),
616                        id: id.to_string(),
617                        client_capabilities: client_caps_v6(),
618                        ..Default::default()
619                    })
620                    .await
621                    .map_err(transport)?
622                    .into_inner();
623                check_diags(resp.diagnostics.iter().map(diag6))?;
624                Ok(resp
625                    .imported_resources
626                    .into_iter()
627                    .next()
628                    .and_then(|ir| ir.state)
629                    .map(from_pb6)
630                    .filter(|d| !d.is_null()))
631            }
632            Client::V5(c) => {
633                let resp = c
634                    .import_resource_state(tfplugin5::import_resource_state::Request {
635                        type_name: type_name.to_string(),
636                        id: id.to_string(),
637                        client_capabilities: client_caps_v5(),
638                        ..Default::default()
639                    })
640                    .await
641                    .map_err(transport)?
642                    .into_inner();
643                check_diags(resp.diagnostics.iter().map(diag5))?;
644                Ok(resp
645                    .imported_resources
646                    .into_iter()
647                    .next()
648                    .and_then(|ir| ir.state)
649                    .map(from_pb5)
650                    .filter(|d| !d.is_null()))
651            }
652        }
653    }
654
655    /// `UpgradeResourceState` — migrate a `StateInstance`'s raw attribute
656    /// JSON (persisted under an older `stored_version` of the provider's
657    /// schema for `type_name`) forward to the CURRENT schema. The
658    /// terraform plugin protocol requires this to run before a stored
659    /// instance is fed into `ReadResource`/`PlanResourceChange`/
660    /// `ApplyResourceChange` whenever `stored_version` is older than the
661    /// provider's live [`ProviderSchema::resource_version`] — decoding
662    /// old-schema JSON straight against the new implied type (skipping
663    /// this call) risks a marshal mismatch or provider-side crash/misparse
664    /// on any resource type whose schema evolved. `raw_json` is the
665    /// instance's raw attribute bytes as stored (magma persists state
666    /// attributes as JSON, never the legacy flatmap format, so only
667    /// `RawState.json` is populated). Returns the upgraded value, decodable
668    /// via `DynamicValue::to_json` against the CURRENT implied type.
669    pub async fn upgrade_resource_state(
670        &mut self,
671        type_name: &str,
672        stored_version: i64,
673        raw_json: &[u8],
674    ) -> Result<DynamicValue, ProviderError> {
675        match &mut self.client {
676            Client::V6(c) => {
677                let resp = c
678                    .upgrade_resource_state(tfplugin6::upgrade_resource_state::Request {
679                        type_name: type_name.to_string(),
680                        version: stored_version,
681                        raw_state: Some(tfplugin6::RawState {
682                            json: raw_json.to_vec(),
683                            flatmap: Default::default(),
684                        }),
685                    })
686                    .await
687                    .map_err(transport)?
688                    .into_inner();
689                check_diags(resp.diagnostics.iter().map(diag6))?;
690                resp.upgraded_state
691                    .map(from_pb6)
692                    .ok_or(ProviderError::NoNewState)
693            }
694            Client::V5(c) => {
695                let resp = c
696                    .upgrade_resource_state(tfplugin5::upgrade_resource_state::Request {
697                        type_name: type_name.to_string(),
698                        version: stored_version,
699                        raw_state: Some(tfplugin5::RawState {
700                            json: raw_json.to_vec(),
701                            flatmap: Default::default(),
702                        }),
703                    })
704                    .await
705                    .map_err(transport)?
706                    .into_inner();
707                check_diags(resp.diagnostics.iter().map(diag5))?;
708                resp.upgraded_state
709                    .map(from_pb5)
710                    .ok_or(ProviderError::NoNewState)
711            }
712        }
713    }
714}
715
716fn transport(s: tonic::Status) -> ProviderError {
717    ProviderError::Transport(s.to_string())
718}
719
720fn to_pb6(dv: &DynamicValue) -> tfplugin6::DynamicValue {
721    tfplugin6::DynamicValue {
722        msgpack: dv.msgpack.clone(),
723        json: Vec::new(),
724    }
725}
726fn from_pb6(dv: tfplugin6::DynamicValue) -> DynamicValue {
727    DynamicValue {
728        msgpack: dv.msgpack,
729    }
730}
731fn to_pb5(dv: &DynamicValue) -> tfplugin5::DynamicValue {
732    tfplugin5::DynamicValue {
733        msgpack: dv.msgpack.clone(),
734        json: Vec::new(),
735    }
736}
737fn from_pb5(dv: tfplugin5::DynamicValue) -> DynamicValue {
738    DynamicValue {
739        msgpack: dv.msgpack,
740    }
741}
742
743/// One `AttributePath.Step` reduced to its selector, independent of
744/// which protocol's generated type it came from — lets
745/// [`render_attribute_path`] be shared by both `_v5`/`_v6` renderers
746/// below instead of duplicating the join logic.
747enum PathStep {
748    Attribute(String),
749    ElementKeyString(String),
750    ElementKeyInt(i64),
751}
752
753/// Render an attribute path as a dotted diagnostic string:
754/// `steps = [Attribute("tags"), ElementKeyString("Name")]` → `"tags.Name"`;
755/// `steps = [Attribute("rules"), ElementKeyInt(2), Attribute("port")]` →
756/// `"rules[2].port"`. See [`PlannedChange::requires_replace`]'s doc for why
757/// this diagnostic shape (not a typed AST) is what callers need.
758fn render_attribute_path(steps: impl Iterator<Item = PathStep>) -> String {
759    let mut out = String::new();
760    for step in steps {
761        match step {
762            PathStep::Attribute(name) => {
763                if !out.is_empty() {
764                    out.push('.');
765                }
766                out.push_str(&name);
767            }
768            PathStep::ElementKeyString(key) => {
769                out.push('[');
770                out.push_str(&key);
771                out.push(']');
772            }
773            PathStep::ElementKeyInt(i) => {
774                out.push('[');
775                out.push_str(&i.to_string());
776                out.push(']');
777            }
778        }
779    }
780    out
781}
782
783fn attribute_path_to_string_v6(path: &tfplugin6::AttributePath) -> String {
784    render_attribute_path(path.steps.iter().map(|s| match &s.selector {
785        Some(tfplugin6::attribute_path::step::Selector::AttributeName(n)) => {
786            PathStep::Attribute(n.clone())
787        }
788        Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(k)) => {
789            PathStep::ElementKeyString(k.clone())
790        }
791        Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)) => {
792            PathStep::ElementKeyInt(*i)
793        }
794        // A step with no selector set is malformed wire data — render as
795        // an empty attribute segment rather than panicking or dropping
796        // the step (which would silently shorten the reported path).
797        None => PathStep::Attribute(String::new()),
798    }))
799}
800
801fn attribute_path_to_string_v5(path: &tfplugin5::AttributePath) -> String {
802    render_attribute_path(path.steps.iter().map(|s| match &s.selector {
803        Some(tfplugin5::attribute_path::step::Selector::AttributeName(n)) => {
804            PathStep::Attribute(n.clone())
805        }
806        Some(tfplugin5::attribute_path::step::Selector::ElementKeyString(k)) => {
807            PathStep::ElementKeyString(k.clone())
808        }
809        Some(tfplugin5::attribute_path::step::Selector::ElementKeyInt(i)) => {
810            PathStep::ElementKeyInt(*i)
811        }
812        None => PathStep::Attribute(String::new()),
813    }))
814}
815
816/// Extract `(severity, summary, detail)` from a tfplugin6 diagnostic.
817fn diag6(d: &tfplugin6::Diagnostic) -> (i32, String, String) {
818    (d.severity, d.summary.clone(), d.detail.clone())
819}
820/// Same for tfplugin5 (identical message shape).
821fn diag5(d: &tfplugin5::Diagnostic) -> (i32, String, String) {
822    (d.severity, d.summary.clone(), d.detail.clone())
823}
824
825/// Fail on any `Error`-severity diagnostic (severity `1` in both
826/// tfplugin5 + tfplugin6); warnings (`2`) are non-fatal. The single
827/// chokepoint that turns provider errors into typed failures rather than
828/// silent success.
829fn error_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Vec<Diag> {
830    diags
831        .filter(|(sev, _, _)| *sev == 1)
832        .map(|(_, summary, detail)| Diag {
833            severity: Severity::Error,
834            summary,
835            detail,
836        })
837        .collect()
838}
839
840/// Decide an `ApplyResourceChange` outcome from the two halves of the
841/// provider's response. Pure, so every combination is directly testable —
842/// the leak this closes lived in an `async fn` that no test could reach.
843///
844/// The load-bearing row is `(errors, Some(state))`: the provider failed AND
845/// committed. Returning `Err` keeps the apply honestly failed, while
846/// `PartiallyApplied` carries the committed state out so the caller can
847/// record it. The old code called `check_diags(...)?` before even looking at
848/// `new_state`, which dropped that row into `Diagnostics` and lost the
849/// resource.
850fn apply_outcome(
851    errs: Vec<Diag>,
852    new_state: Option<DynamicValue>,
853) -> Result<DynamicValue, ProviderError> {
854    match (errs.is_empty(), new_state) {
855        (true, Some(dv)) => Ok(dv),
856        (true, None) => Err(ProviderError::NoNewState),
857        (false, Some(dv)) => Err(ProviderError::PartiallyApplied {
858            diags: errs,
859            state: Box::new(dv),
860        }),
861        (false, None) => Err(ProviderError::Diagnostics(errs)),
862    }
863}
864
865fn check_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Result<(), ProviderError> {
866    let errors = error_diags(diags);
867    if errors.is_empty() {
868        Ok(())
869    } else {
870        Err(ProviderError::Diagnostics(errors))
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    fn err_diag(msg: &str) -> Vec<Diag> {
879        vec![Diag {
880            severity: Severity::Error,
881            summary: msg.to_string(),
882            detail: String::new(),
883        }]
884    }
885
886    /// The shape that leaked: an EIP whose allocation COMMITTED.
887    fn eip_type() -> CtyType {
888        CtyType::Object(BTreeMap::from([("id".to_string(), CtyType::String)]))
889    }
890
891    fn some_state() -> DynamicValue {
892        DynamicValue::from_json(&serde_json::json!({"id": "eipalloc-1"}), &eip_type())
893            .expect("test fixture must encode")
894    }
895
896    /// The row that leaked money: the provider FAILED but COMMITTED. The
897    /// committed state must survive the error, or the next plan creates a
898    /// duplicate (two orphaned EIPs, example, 2026-08-01).
899    #[test]
900    fn error_with_new_state_is_partially_applied_and_keeps_the_state() {
901        let out = apply_outcome(err_diag("tagging failed"), Some(some_state()));
902        match out {
903            Err(ProviderError::PartiallyApplied { diags, state }) => {
904                assert_eq!(diags.len(), 1);
905                assert_eq!(diags[0].summary, "tagging failed");
906                // Not merely present — the allocation id must survive intact,
907                // since that is what the next plan needs to avoid re-creating.
908                let attrs = state
909                    .to_json(&eip_type())
910                    .expect("partial state must decode");
911                assert_eq!(attrs["id"], "eipalloc-1");
912            }
913            other => panic!("expected PartiallyApplied, got {other:?}"),
914        }
915    }
916
917    #[test]
918    fn error_without_new_state_stays_plain_diagnostics() {
919        assert!(matches!(
920            apply_outcome(err_diag("boom"), None),
921            Err(ProviderError::Diagnostics(_))
922        ));
923    }
924
925    #[test]
926    fn clean_apply_with_state_is_ok() {
927        assert!(apply_outcome(Vec::new(), Some(some_state())).is_ok());
928    }
929
930    #[test]
931    fn clean_apply_without_state_is_no_new_state() {
932        assert!(matches!(
933            apply_outcome(Vec::new(), None),
934            Err(ProviderError::NoNewState)
935        ));
936    }
937
938    /// A partial apply is NEVER retryable, whatever the diagnostic text says.
939    /// `apply_resource_change` is not idempotent; re-issuing a create whose
940    /// resource already landed allocates a SECOND one. This is the guard that
941    /// keeps `rpc_retry!` (up to 7 attempts) from multiplying the leak.
942    #[test]
943    fn a_partial_apply_is_never_retryable_even_when_the_text_looks_transient() {
944        let e = ProviderError::PartiallyApplied {
945            // Wording chosen to match the transient substring oracle.
946            diags: err_diag("connection reset by peer: timeout"),
947            state: Box::new(some_state()),
948        };
949        assert!(
950            !is_retryable(&e),
951            "retrying a committed resource duplicates it"
952        );
953    }
954
955    #[test]
956    fn empty_diagnostics_is_ok() {
957        assert!(check_diags(std::iter::empty()).is_ok());
958    }
959
960    #[test]
961    fn warning_only_is_ok() {
962        let diags = vec![(2, "heads up".to_string(), String::new())];
963        assert!(check_diags(diags.into_iter()).is_ok());
964    }
965
966    #[test]
967    fn any_error_diagnostic_fails() {
968        let diags = vec![
969            (2, "warn".to_string(), String::new()),
970            (1, "boom".to_string(), "bad".to_string()),
971        ];
972        match check_diags(diags.into_iter()) {
973            Err(ProviderError::Diagnostics(errs)) => {
974                assert_eq!(errs.len(), 1, "only error-severity diags are fatal");
975                assert_eq!(errs[0].summary, "boom");
976            }
977            other => panic!("expected Diagnostics error, got {other:?}"),
978        }
979    }
980
981    #[test]
982    fn dynamic_value_pb_roundtrip_both_protocols() {
983        let dv = DynamicValue {
984            msgpack: vec![0xc0, 0x01, 0x02],
985        };
986        assert_eq!(from_pb6(to_pb6(&dv)), dv);
987        assert_eq!(from_pb5(to_pb5(&dv)), dv);
988        assert!(to_pb6(&dv).json.is_empty());
989    }
990
991    fn v6_attr_step(name: &str) -> tfplugin6::attribute_path::Step {
992        tfplugin6::attribute_path::Step {
993            selector: Some(tfplugin6::attribute_path::step::Selector::AttributeName(
994                name.to_string(),
995            )),
996        }
997    }
998
999    fn v6_index_step(i: i64) -> tfplugin6::attribute_path::Step {
1000        tfplugin6::attribute_path::Step {
1001            selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)),
1002        }
1003    }
1004
1005    fn v6_key_step(k: &str) -> tfplugin6::attribute_path::Step {
1006        tfplugin6::attribute_path::Step {
1007            selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(
1008                k.to_string(),
1009            )),
1010        }
1011    }
1012
1013    #[test]
1014    fn attribute_path_to_string_v6_single_attribute() {
1015        let path = tfplugin6::AttributePath {
1016            steps: vec![v6_attr_step("instance_types")],
1017        };
1018        assert_eq!(attribute_path_to_string_v6(&path), "instance_types");
1019    }
1020
1021    #[test]
1022    fn attribute_path_to_string_v6_nested_key() {
1023        let path = tfplugin6::AttributePath {
1024            steps: vec![v6_attr_step("tags"), v6_key_step("Name")],
1025        };
1026        assert_eq!(attribute_path_to_string_v6(&path), "tags[Name]");
1027    }
1028
1029    #[test]
1030    fn attribute_path_to_string_v6_indexed_then_attribute() {
1031        let path = tfplugin6::AttributePath {
1032            steps: vec![
1033                v6_attr_step("rules"),
1034                v6_index_step(2),
1035                v6_attr_step("port"),
1036            ],
1037        };
1038        assert_eq!(attribute_path_to_string_v6(&path), "rules[2].port");
1039    }
1040
1041    fn v5_attr_step(name: &str) -> tfplugin5::attribute_path::Step {
1042        tfplugin5::attribute_path::Step {
1043            selector: Some(tfplugin5::attribute_path::step::Selector::AttributeName(
1044                name.to_string(),
1045            )),
1046        }
1047    }
1048
1049    #[test]
1050    fn attribute_path_to_string_v5_matches_v6_shape() {
1051        let path = tfplugin5::AttributePath {
1052            steps: vec![v5_attr_step("ami")],
1053        };
1054        assert_eq!(attribute_path_to_string_v5(&path), "ami");
1055    }
1056
1057    /// The `plan_resource_change`/`apply_resource_change` wire round-trip
1058    /// this crate exists to speak has one job for the requires-replace
1059    /// signal: never drop it. A `PlannedChange` with an empty vec must be
1060    /// distinguishable from one with paths in it — `requires_replace()`'s
1061    /// consumer (`magma-apply::engine::apply_one`) branches on exactly
1062    /// this emptiness check.
1063    #[test]
1064    fn planned_change_requires_replace_is_empty_iff_no_paths() {
1065        let no_replace = PlannedChange {
1066            state: DynamicValue {
1067                msgpack: vec![0xc0],
1068            },
1069            requires_replace: vec![],
1070        };
1071        let must_replace = PlannedChange {
1072            state: DynamicValue {
1073                msgpack: vec![0xc0],
1074            },
1075            requires_replace: vec!["instance_types".to_string()],
1076        };
1077        assert!(no_replace.requires_replace.is_empty());
1078        assert!(!must_replace.requires_replace.is_empty());
1079    }
1080}