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;
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/// ── THE PROTOCOL DATA MODEL MOVED TO `magma-provider-api` ────────────
72/// `ProviderSchema`, `PlannedChange`, `Diag`, `Severity`, `ProviderError`
73/// and `is_retryable` were defined here. They describe what a provider
74/// SAYS, not how it is reached, so they now live in the contract crate —
75/// which depends on `magma-cty` alone, so a native provider can implement
76/// the contract without dragging in tonic.
77///
78/// The edge had to point this way: `magma-provider-api` declares the
79/// `Provider` trait, and THIS crate implements it, so the types could not
80/// stay in the crate the trait crate would have had to depend on.
81///
82/// Re-exported unchanged, so every `magma_plugin::provider::<T>` path
83/// still resolves. Relocation, not redesign.
84pub use magma_provider_api::{
85    Diag, PlannedChange, Provider, ProviderError, ProviderSchema, Severity, is_retryable,
86};
87
88impl ProviderConn {
89    /// Wrap a dialed channel, selecting the client by the handshake's
90    /// negotiated protocol (`Plugin::handshake().app_protocol`).
91    pub fn new(channel: H2Channel, protocol: PluginProtocol) -> Self {
92        let client = match protocol {
93            PluginProtocol::V5 => {
94                Client::V5(Client5::new(channel).max_decoding_message_size(256 * 1024 * 1024))
95            }
96            PluginProtocol::V6 => {
97                Client::V6(Client6::new(channel).max_decoding_message_size(256 * 1024 * 1024))
98            }
99        };
100        Self { client }
101    }
102
103    /// `GetProviderSchema` (v6) / `GetSchema` (v5) → provider-config +
104    /// per-resource implied types.
105    pub async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
106        match &mut self.client {
107            Client::V6(c) => {
108                let resp = c
109                    .get_provider_schema(tfplugin6::get_provider_schema::Request::default())
110                    .await
111                    .map_err(transport)?
112                    .into_inner();
113                check_diags(resp.diagnostics.iter().map(diag6))?;
114                let provider_config = match resp.provider.and_then(|s| s.block) {
115                    Some(b) => schema::block_implied_type(&b)?,
116                    None => CtyType::Object(BTreeMap::new()),
117                };
118                let mut resources = BTreeMap::new();
119                let mut resource_versions = BTreeMap::new();
120                for (name, sch) in resp.resource_schemas {
121                    // Capture the schema version regardless of whether the
122                    // block parses, so a resource_version() lookup is never
123                    // silently missing for a type whose implied type failed
124                    // to decode.
125                    resource_versions.insert(name.clone(), sch.version);
126                    if let Some(b) = sch.block {
127                        resources.insert(name, schema::block_implied_type(&b)?);
128                    }
129                }
130                let mut data_sources = BTreeMap::new();
131                for (name, sch) in resp.data_source_schemas {
132                    if let Some(b) = sch.block {
133                        data_sources.insert(name, schema::block_implied_type(&b)?);
134                    }
135                }
136                Ok(ProviderSchema {
137                    provider_config,
138                    resources,
139                    data_sources,
140                    resource_versions,
141                })
142            }
143            Client::V5(c) => {
144                let resp = c
145                    .get_schema(tfplugin5::get_provider_schema::Request::default())
146                    .await
147                    .map_err(transport)?
148                    .into_inner();
149                check_diags(resp.diagnostics.iter().map(diag5))?;
150                let provider_config = match resp.provider.and_then(|s| s.block) {
151                    Some(b) => schema::block5_implied_type(&b)?,
152                    None => CtyType::Object(BTreeMap::new()),
153                };
154                let mut resources = BTreeMap::new();
155                let mut resource_versions = BTreeMap::new();
156                for (name, sch) in resp.resource_schemas {
157                    resource_versions.insert(name.clone(), sch.version);
158                    if let Some(b) = sch.block {
159                        resources.insert(name, schema::block5_implied_type(&b)?);
160                    }
161                }
162                let mut data_sources = BTreeMap::new();
163                for (name, sch) in resp.data_source_schemas {
164                    if let Some(b) = sch.block {
165                        data_sources.insert(name, schema::block5_implied_type(&b)?);
166                    }
167                }
168                Ok(ProviderSchema {
169                    provider_config,
170                    resources,
171                    data_sources,
172                    resource_versions,
173                })
174            }
175        }
176    }
177
178    /// `ConfigureProvider` (v6) / `Configure` (v5) — provider creds/settings.
179    pub async fn configure(
180        &mut self,
181        config: &DynamicValue,
182        terraform_version: &str,
183    ) -> Result<(), ProviderError> {
184        match &mut self.client {
185            Client::V6(c) => {
186                let resp = c
187                    .configure_provider(tfplugin6::configure_provider::Request {
188                        terraform_version: terraform_version.to_string(),
189                        config: Some(to_pb6(config)),
190                        client_capabilities: client_caps_v6(),
191                        ..Default::default()
192                    })
193                    .await
194                    .map_err(transport)?
195                    .into_inner();
196                check_diags(resp.diagnostics.iter().map(diag6))
197            }
198            Client::V5(c) => {
199                let resp = c
200                    .configure(tfplugin5::configure::Request {
201                        terraform_version: terraform_version.to_string(),
202                        config: Some(to_pb5(config)),
203                        client_capabilities: client_caps_v5(),
204                        ..Default::default()
205                    })
206                    .await
207                    .map_err(transport)?
208                    .into_inner();
209                check_diags(resp.diagnostics.iter().map(diag5))
210            }
211        }
212    }
213
214    /// `PlanResourceChange` — the provider's proposed new state PLUS
215    /// which attribute paths (if any) force a destroy+create instead of
216    /// an in-place update. See [`PlannedChange`]'s doc for why the
217    /// latter matters: it is the ONLY authoritative source for that
218    /// decision, and was silently discarded here before `PlannedChange`
219    /// existed.
220    pub async fn plan_resource_change(
221        &mut self,
222        type_name: &str,
223        prior_state: &DynamicValue,
224        proposed_new_state: &DynamicValue,
225        config: &DynamicValue,
226    ) -> Result<PlannedChange, ProviderError> {
227        match &mut self.client {
228            Client::V6(c) => {
229                let resp = c
230                    .plan_resource_change(tfplugin6::plan_resource_change::Request {
231                        type_name: type_name.to_string(),
232                        prior_state: Some(to_pb6(prior_state)),
233                        proposed_new_state: Some(to_pb6(proposed_new_state)),
234                        config: Some(to_pb6(config)),
235                        client_capabilities: client_caps_v6(),
236                        ..Default::default()
237                    })
238                    .await
239                    .map_err(transport)?
240                    .into_inner();
241                check_diags(resp.diagnostics.iter().map(diag6))?;
242                let requires_replace = resp
243                    .requires_replace
244                    .iter()
245                    .map(attribute_path_to_string_v6)
246                    .collect();
247                let state = resp
248                    .planned_state
249                    .map(from_pb6)
250                    .ok_or(ProviderError::NoNewState)?;
251                Ok(PlannedChange {
252                    state,
253                    requires_replace,
254                })
255            }
256            Client::V5(c) => {
257                let resp = c
258                    .plan_resource_change(tfplugin5::plan_resource_change::Request {
259                        type_name: type_name.to_string(),
260                        prior_state: Some(to_pb5(prior_state)),
261                        proposed_new_state: Some(to_pb5(proposed_new_state)),
262                        config: Some(to_pb5(config)),
263                        client_capabilities: client_caps_v5(),
264                        ..Default::default()
265                    })
266                    .await
267                    .map_err(transport)?
268                    .into_inner();
269                check_diags(resp.diagnostics.iter().map(diag5))?;
270                let requires_replace = resp
271                    .requires_replace
272                    .iter()
273                    .map(attribute_path_to_string_v5)
274                    .collect();
275                let state = resp
276                    .planned_state
277                    .map(from_pb5)
278                    .ok_or(ProviderError::NoNewState)?;
279                Ok(PlannedChange {
280                    state,
281                    requires_replace,
282                })
283            }
284        }
285    }
286
287    /// `ApplyResourceChange` — execute the change. Returns the new state.
288    pub async fn apply_resource_change(
289        &mut self,
290        type_name: &str,
291        prior_state: &DynamicValue,
292        planned_state: &DynamicValue,
293        config: &DynamicValue,
294    ) -> Result<DynamicValue, ProviderError> {
295        match &mut self.client {
296            Client::V6(c) => {
297                let resp = c
298                    .apply_resource_change(tfplugin6::apply_resource_change::Request {
299                        type_name: type_name.to_string(),
300                        prior_state: Some(to_pb6(prior_state)),
301                        planned_state: Some(to_pb6(planned_state)),
302                        config: Some(to_pb6(config)),
303                        ..Default::default()
304                    })
305                    .await
306                    .map_err(transport)?
307                    .into_inner();
308                // Read `new_state` BEFORE deciding on diagnostics. The old order
309                // was `check_diags(...)?` first, which discarded a committed
310                // resource whenever the provider reported an error alongside it
311                // — the partial-apply leak (see ProviderError::PartiallyApplied).
312                apply_outcome(
313                    error_diags(resp.diagnostics.iter().map(diag6)),
314                    resp.new_state.map(from_pb6),
315                )
316            }
317            Client::V5(c) => {
318                let resp = c
319                    .apply_resource_change(tfplugin5::apply_resource_change::Request {
320                        type_name: type_name.to_string(),
321                        prior_state: Some(to_pb5(prior_state)),
322                        planned_state: Some(to_pb5(planned_state)),
323                        config: Some(to_pb5(config)),
324                        ..Default::default()
325                    })
326                    .await
327                    .map_err(transport)?
328                    .into_inner();
329                // Read `new_state` BEFORE deciding on diagnostics. The old order
330                // was `check_diags(...)?` first, which discarded a committed
331                // resource whenever the provider reported an error alongside it
332                // — the partial-apply leak (see ProviderError::PartiallyApplied).
333                apply_outcome(
334                    error_diags(resp.diagnostics.iter().map(diag5)),
335                    resp.new_state.map(from_pb5),
336                )
337            }
338        }
339    }
340
341    /// `ReadResource` — read the resource's ACTUAL current state from the
342    /// provider (the refresh primitive). Returns `Ok(None)` when the provider
343    /// reports the resource no longer exists (`new_state` is cty-null), so
344    /// callers drop stale / phantom entries from state; `Ok(Some(dv))` with
345    /// the refreshed wire state when it still exists.
346    pub async fn read_resource(
347        &mut self,
348        type_name: &str,
349        current_state: &DynamicValue,
350    ) -> Result<Option<DynamicValue>, ProviderError> {
351        match &mut self.client {
352            Client::V6(c) => {
353                let resp = c
354                    .read_resource(tfplugin6::read_resource::Request {
355                        type_name: type_name.to_string(),
356                        current_state: Some(to_pb6(current_state)),
357                        client_capabilities: client_caps_v6(),
358                        ..Default::default()
359                    })
360                    .await
361                    .map_err(transport)?
362                    .into_inner();
363                check_diags(resp.diagnostics.iter().map(diag6))?;
364                Ok(resp.new_state.map(from_pb6).filter(|d| !d.is_null()))
365            }
366            Client::V5(c) => {
367                let resp = c
368                    .read_resource(tfplugin5::read_resource::Request {
369                        type_name: type_name.to_string(),
370                        current_state: Some(to_pb5(current_state)),
371                        client_capabilities: client_caps_v5(),
372                        ..Default::default()
373                    })
374                    .await
375                    .map_err(transport)?
376                    .into_inner();
377                check_diags(resp.diagnostics.iter().map(diag5))?;
378                Ok(resp.new_state.map(from_pb5).filter(|d| !d.is_null()))
379            }
380        }
381    }
382
383    /// `ReadDataSource` — evaluate a `data` block by querying the provider,
384    /// returning its result state. The apply engine reads data sources up front
385    /// so `${data.<type>.<name>.<attr>}` references resolve; without it those
386    /// strings leaked verbatim to managed-resource RPCs (the rio-drive
387    /// Cloudflare 400). `Ok(None)` if the provider returned cty-null.
388    pub async fn read_data_source(
389        &mut self,
390        type_name: &str,
391        config: &DynamicValue,
392    ) -> Result<Option<DynamicValue>, ProviderError> {
393        match &mut self.client {
394            Client::V6(c) => {
395                let resp = c
396                    .read_data_source(tfplugin6::read_data_source::Request {
397                        type_name: type_name.to_string(),
398                        config: Some(to_pb6(config)),
399                        client_capabilities: client_caps_v6(),
400                        ..Default::default()
401                    })
402                    .await
403                    .map_err(transport)?
404                    .into_inner();
405                check_diags(resp.diagnostics.iter().map(diag6))?;
406                Ok(resp.state.map(from_pb6).filter(|d| !d.is_null()))
407            }
408            Client::V5(c) => {
409                let resp = c
410                    .read_data_source(tfplugin5::read_data_source::Request {
411                        type_name: type_name.to_string(),
412                        config: Some(to_pb5(config)),
413                        client_capabilities: client_caps_v5(),
414                        ..Default::default()
415                    })
416                    .await
417                    .map_err(transport)?
418                    .into_inner();
419                check_diags(resp.diagnostics.iter().map(diag5))?;
420                Ok(resp.state.map(from_pb5).filter(|d| !d.is_null()))
421            }
422        }
423    }
424
425    /// `ImportResourceState` — adopt a resource that EXISTS in the cloud but
426    /// is absent from magma's state, by its provider-native import id (e.g.
427    /// the repo name for `github_repository`). Returns the imported wire state
428    /// (`Ok(Some(dv))`) or `Ok(None)` if the provider imported nothing /
429    /// returned cty-null. This is the read/import half of the protocol that
430    /// powers import-on-create-conflict (422 already-exists → observe → adopt)
431    /// + `magma import`. Mirrors apply/read's V5/V6 dispatch + msgpack decode.
432    pub async fn import_resource_state(
433        &mut self,
434        type_name: &str,
435        id: &str,
436    ) -> Result<Option<DynamicValue>, ProviderError> {
437        match &mut self.client {
438            Client::V6(c) => {
439                let resp = c
440                    .import_resource_state(tfplugin6::import_resource_state::Request {
441                        type_name: type_name.to_string(),
442                        id: id.to_string(),
443                        client_capabilities: client_caps_v6(),
444                        ..Default::default()
445                    })
446                    .await
447                    .map_err(transport)?
448                    .into_inner();
449                check_diags(resp.diagnostics.iter().map(diag6))?;
450                Ok(resp
451                    .imported_resources
452                    .into_iter()
453                    .next()
454                    .and_then(|ir| ir.state)
455                    .map(from_pb6)
456                    .filter(|d| !d.is_null()))
457            }
458            Client::V5(c) => {
459                let resp = c
460                    .import_resource_state(tfplugin5::import_resource_state::Request {
461                        type_name: type_name.to_string(),
462                        id: id.to_string(),
463                        client_capabilities: client_caps_v5(),
464                        ..Default::default()
465                    })
466                    .await
467                    .map_err(transport)?
468                    .into_inner();
469                check_diags(resp.diagnostics.iter().map(diag5))?;
470                Ok(resp
471                    .imported_resources
472                    .into_iter()
473                    .next()
474                    .and_then(|ir| ir.state)
475                    .map(from_pb5)
476                    .filter(|d| !d.is_null()))
477            }
478        }
479    }
480
481    /// `UpgradeResourceState` — migrate a `StateInstance`'s raw attribute
482    /// JSON (persisted under an older `stored_version` of the provider's
483    /// schema for `type_name`) forward to the CURRENT schema. The
484    /// terraform plugin protocol requires this to run before a stored
485    /// instance is fed into `ReadResource`/`PlanResourceChange`/
486    /// `ApplyResourceChange` whenever `stored_version` is older than the
487    /// provider's live [`ProviderSchema::resource_version`] — decoding
488    /// old-schema JSON straight against the new implied type (skipping
489    /// this call) risks a marshal mismatch or provider-side crash/misparse
490    /// on any resource type whose schema evolved. `raw_json` is the
491    /// instance's raw attribute bytes as stored (magma persists state
492    /// attributes as JSON, never the legacy flatmap format, so only
493    /// `RawState.json` is populated). Returns the upgraded value, decodable
494    /// via `DynamicValue::to_json` against the CURRENT implied type.
495    pub async fn upgrade_resource_state(
496        &mut self,
497        type_name: &str,
498        stored_version: i64,
499        raw_json: &[u8],
500    ) -> Result<DynamicValue, ProviderError> {
501        match &mut self.client {
502            Client::V6(c) => {
503                let resp = c
504                    .upgrade_resource_state(tfplugin6::upgrade_resource_state::Request {
505                        type_name: type_name.to_string(),
506                        version: stored_version,
507                        raw_state: Some(tfplugin6::RawState {
508                            json: raw_json.to_vec(),
509                            flatmap: Default::default(),
510                        }),
511                    })
512                    .await
513                    .map_err(transport)?
514                    .into_inner();
515                check_diags(resp.diagnostics.iter().map(diag6))?;
516                resp.upgraded_state
517                    .map(from_pb6)
518                    .ok_or(ProviderError::NoNewState)
519            }
520            Client::V5(c) => {
521                let resp = c
522                    .upgrade_resource_state(tfplugin5::upgrade_resource_state::Request {
523                        type_name: type_name.to_string(),
524                        version: stored_version,
525                        raw_state: Some(tfplugin5::RawState {
526                            json: raw_json.to_vec(),
527                            flatmap: Default::default(),
528                        }),
529                    })
530                    .await
531                    .map_err(transport)?
532                    .into_inner();
533                check_diags(resp.diagnostics.iter().map(diag5))?;
534                resp.upgraded_state
535                    .map(from_pb5)
536                    .ok_or(ProviderError::NoNewState)
537            }
538        }
539    }
540}
541
542fn transport(s: tonic::Status) -> ProviderError {
543    ProviderError::Transport(s.to_string())
544}
545
546fn to_pb6(dv: &DynamicValue) -> tfplugin6::DynamicValue {
547    tfplugin6::DynamicValue {
548        msgpack: dv.msgpack.clone(),
549        json: Vec::new(),
550    }
551}
552fn from_pb6(dv: tfplugin6::DynamicValue) -> DynamicValue {
553    DynamicValue {
554        msgpack: dv.msgpack,
555    }
556}
557fn to_pb5(dv: &DynamicValue) -> tfplugin5::DynamicValue {
558    tfplugin5::DynamicValue {
559        msgpack: dv.msgpack.clone(),
560        json: Vec::new(),
561    }
562}
563fn from_pb5(dv: tfplugin5::DynamicValue) -> DynamicValue {
564    DynamicValue {
565        msgpack: dv.msgpack,
566    }
567}
568
569/// One `AttributePath.Step` reduced to its selector, independent of
570/// which protocol's generated type it came from — lets
571/// [`render_attribute_path`] be shared by both `_v5`/`_v6` renderers
572/// below instead of duplicating the join logic.
573enum PathStep {
574    Attribute(String),
575    ElementKeyString(String),
576    ElementKeyInt(i64),
577}
578
579/// Render an attribute path as a dotted diagnostic string:
580/// `steps = [Attribute("tags"), ElementKeyString("Name")]` → `"tags.Name"`;
581/// `steps = [Attribute("rules"), ElementKeyInt(2), Attribute("port")]` →
582/// `"rules[2].port"`. See [`PlannedChange::requires_replace`]'s doc for why
583/// this diagnostic shape (not a typed AST) is what callers need.
584fn render_attribute_path(steps: impl Iterator<Item = PathStep>) -> String {
585    let mut out = String::new();
586    for step in steps {
587        match step {
588            PathStep::Attribute(name) => {
589                if !out.is_empty() {
590                    out.push('.');
591                }
592                out.push_str(&name);
593            }
594            PathStep::ElementKeyString(key) => {
595                out.push('[');
596                out.push_str(&key);
597                out.push(']');
598            }
599            PathStep::ElementKeyInt(i) => {
600                out.push('[');
601                out.push_str(&i.to_string());
602                out.push(']');
603            }
604        }
605    }
606    out
607}
608
609fn attribute_path_to_string_v6(path: &tfplugin6::AttributePath) -> String {
610    render_attribute_path(path.steps.iter().map(|s| match &s.selector {
611        Some(tfplugin6::attribute_path::step::Selector::AttributeName(n)) => {
612            PathStep::Attribute(n.clone())
613        }
614        Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(k)) => {
615            PathStep::ElementKeyString(k.clone())
616        }
617        Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)) => {
618            PathStep::ElementKeyInt(*i)
619        }
620        // A step with no selector set is malformed wire data — render as
621        // an empty attribute segment rather than panicking or dropping
622        // the step (which would silently shorten the reported path).
623        None => PathStep::Attribute(String::new()),
624    }))
625}
626
627fn attribute_path_to_string_v5(path: &tfplugin5::AttributePath) -> String {
628    render_attribute_path(path.steps.iter().map(|s| match &s.selector {
629        Some(tfplugin5::attribute_path::step::Selector::AttributeName(n)) => {
630            PathStep::Attribute(n.clone())
631        }
632        Some(tfplugin5::attribute_path::step::Selector::ElementKeyString(k)) => {
633            PathStep::ElementKeyString(k.clone())
634        }
635        Some(tfplugin5::attribute_path::step::Selector::ElementKeyInt(i)) => {
636            PathStep::ElementKeyInt(*i)
637        }
638        None => PathStep::Attribute(String::new()),
639    }))
640}
641
642/// Extract `(severity, summary, detail)` from a tfplugin6 diagnostic.
643fn diag6(d: &tfplugin6::Diagnostic) -> (i32, String, String) {
644    (d.severity, d.summary.clone(), d.detail.clone())
645}
646/// Same for tfplugin5 (identical message shape).
647fn diag5(d: &tfplugin5::Diagnostic) -> (i32, String, String) {
648    (d.severity, d.summary.clone(), d.detail.clone())
649}
650
651/// Fail on any `Error`-severity diagnostic (severity `1` in both
652/// tfplugin5 + tfplugin6); warnings (`2`) are non-fatal. The single
653/// chokepoint that turns provider errors into typed failures rather than
654/// silent success.
655fn error_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Vec<Diag> {
656    diags
657        .filter(|(sev, _, _)| *sev == 1)
658        .map(|(_, summary, detail)| Diag {
659            severity: Severity::Error,
660            summary,
661            detail,
662        })
663        .collect()
664}
665
666/// Decide an `ApplyResourceChange` outcome from the two halves of the
667/// provider's response. Pure, so every combination is directly testable —
668/// the leak this closes lived in an `async fn` that no test could reach.
669///
670/// The load-bearing row is `(errors, Some(state))`: the provider failed AND
671/// committed. Returning `Err` keeps the apply honestly failed, while
672/// `PartiallyApplied` carries the committed state out so the caller can
673/// record it. The old code called `check_diags(...)?` before even looking at
674/// `new_state`, which dropped that row into `Diagnostics` and lost the
675/// resource.
676fn apply_outcome(
677    errs: Vec<Diag>,
678    new_state: Option<DynamicValue>,
679) -> Result<DynamicValue, ProviderError> {
680    match (errs.is_empty(), new_state) {
681        (true, Some(dv)) => Ok(dv),
682        (true, None) => Err(ProviderError::NoNewState),
683        (false, Some(dv)) => Err(ProviderError::PartiallyApplied {
684            diags: errs,
685            state: Box::new(dv),
686        }),
687        (false, None) => Err(ProviderError::Diagnostics(errs)),
688    }
689}
690
691fn check_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Result<(), ProviderError> {
692    let errors = error_diags(diags);
693    if errors.is_empty() {
694        Ok(())
695    } else {
696        Err(ProviderError::Diagnostics(errors))
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    fn err_diag(msg: &str) -> Vec<Diag> {
705        vec![Diag {
706            severity: Severity::Error,
707            summary: msg.to_string(),
708            detail: String::new(),
709        }]
710    }
711
712    /// The shape that leaked: an EIP whose allocation COMMITTED.
713    fn eip_type() -> CtyType {
714        CtyType::Object(BTreeMap::from([("id".to_string(), CtyType::String)]))
715    }
716
717    fn some_state() -> DynamicValue {
718        DynamicValue::from_json(&serde_json::json!({"id": "eipalloc-1"}), &eip_type())
719            .expect("test fixture must encode")
720    }
721
722    /// The row that leaked money: the provider FAILED but COMMITTED. The
723    /// committed state must survive the error, or the next plan creates a
724    /// duplicate (two orphaned EIPs, example, 2026-08-01).
725    #[test]
726    fn error_with_new_state_is_partially_applied_and_keeps_the_state() {
727        let out = apply_outcome(err_diag("tagging failed"), Some(some_state()));
728        match out {
729            Err(ProviderError::PartiallyApplied { diags, state }) => {
730                assert_eq!(diags.len(), 1);
731                assert_eq!(diags[0].summary, "tagging failed");
732                // Not merely present — the allocation id must survive intact,
733                // since that is what the next plan needs to avoid re-creating.
734                let attrs = state
735                    .to_json(&eip_type())
736                    .expect("partial state must decode");
737                assert_eq!(attrs["id"], "eipalloc-1");
738            }
739            other => panic!("expected PartiallyApplied, got {other:?}"),
740        }
741    }
742
743    #[test]
744    fn error_without_new_state_stays_plain_diagnostics() {
745        assert!(matches!(
746            apply_outcome(err_diag("boom"), None),
747            Err(ProviderError::Diagnostics(_))
748        ));
749    }
750
751    #[test]
752    fn clean_apply_with_state_is_ok() {
753        assert!(apply_outcome(Vec::new(), Some(some_state())).is_ok());
754    }
755
756    #[test]
757    fn clean_apply_without_state_is_no_new_state() {
758        assert!(matches!(
759            apply_outcome(Vec::new(), None),
760            Err(ProviderError::NoNewState)
761        ));
762    }
763
764    /// A partial apply is NEVER retryable, whatever the diagnostic text says.
765    /// `apply_resource_change` is not idempotent; re-issuing a create whose
766    /// resource already landed allocates a SECOND one. This is the guard that
767    /// keeps `rpc_retry!` (up to 7 attempts) from multiplying the leak.
768    #[test]
769    fn a_partial_apply_is_never_retryable_even_when_the_text_looks_transient() {
770        let e = ProviderError::PartiallyApplied {
771            // Wording chosen to match the transient substring oracle.
772            diags: err_diag("connection reset by peer: timeout"),
773            state: Box::new(some_state()),
774        };
775        assert!(
776            !is_retryable(&e),
777            "retrying a committed resource duplicates it"
778        );
779    }
780
781    #[test]
782    fn empty_diagnostics_is_ok() {
783        assert!(check_diags(std::iter::empty()).is_ok());
784    }
785
786    #[test]
787    fn warning_only_is_ok() {
788        let diags = vec![(2, "heads up".to_string(), String::new())];
789        assert!(check_diags(diags.into_iter()).is_ok());
790    }
791
792    #[test]
793    fn any_error_diagnostic_fails() {
794        let diags = vec![
795            (2, "warn".to_string(), String::new()),
796            (1, "boom".to_string(), "bad".to_string()),
797        ];
798        match check_diags(diags.into_iter()) {
799            Err(ProviderError::Diagnostics(errs)) => {
800                assert_eq!(errs.len(), 1, "only error-severity diags are fatal");
801                assert_eq!(errs[0].summary, "boom");
802            }
803            other => panic!("expected Diagnostics error, got {other:?}"),
804        }
805    }
806
807    #[test]
808    fn dynamic_value_pb_roundtrip_both_protocols() {
809        let dv = DynamicValue {
810            msgpack: vec![0xc0, 0x01, 0x02],
811        };
812        assert_eq!(from_pb6(to_pb6(&dv)), dv);
813        assert_eq!(from_pb5(to_pb5(&dv)), dv);
814        assert!(to_pb6(&dv).json.is_empty());
815    }
816
817    fn v6_attr_step(name: &str) -> tfplugin6::attribute_path::Step {
818        tfplugin6::attribute_path::Step {
819            selector: Some(tfplugin6::attribute_path::step::Selector::AttributeName(
820                name.to_string(),
821            )),
822        }
823    }
824
825    fn v6_index_step(i: i64) -> tfplugin6::attribute_path::Step {
826        tfplugin6::attribute_path::Step {
827            selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)),
828        }
829    }
830
831    fn v6_key_step(k: &str) -> tfplugin6::attribute_path::Step {
832        tfplugin6::attribute_path::Step {
833            selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(
834                k.to_string(),
835            )),
836        }
837    }
838
839    #[test]
840    fn attribute_path_to_string_v6_single_attribute() {
841        let path = tfplugin6::AttributePath {
842            steps: vec![v6_attr_step("instance_types")],
843        };
844        assert_eq!(attribute_path_to_string_v6(&path), "instance_types");
845    }
846
847    #[test]
848    fn attribute_path_to_string_v6_nested_key() {
849        let path = tfplugin6::AttributePath {
850            steps: vec![v6_attr_step("tags"), v6_key_step("Name")],
851        };
852        assert_eq!(attribute_path_to_string_v6(&path), "tags[Name]");
853    }
854
855    #[test]
856    fn attribute_path_to_string_v6_indexed_then_attribute() {
857        let path = tfplugin6::AttributePath {
858            steps: vec![
859                v6_attr_step("rules"),
860                v6_index_step(2),
861                v6_attr_step("port"),
862            ],
863        };
864        assert_eq!(attribute_path_to_string_v6(&path), "rules[2].port");
865    }
866
867    fn v5_attr_step(name: &str) -> tfplugin5::attribute_path::Step {
868        tfplugin5::attribute_path::Step {
869            selector: Some(tfplugin5::attribute_path::step::Selector::AttributeName(
870                name.to_string(),
871            )),
872        }
873    }
874
875    #[test]
876    fn attribute_path_to_string_v5_matches_v6_shape() {
877        let path = tfplugin5::AttributePath {
878            steps: vec![v5_attr_step("ami")],
879        };
880        assert_eq!(attribute_path_to_string_v5(&path), "ami");
881    }
882
883    /// The `plan_resource_change`/`apply_resource_change` wire round-trip
884    /// this crate exists to speak has one job for the requires-replace
885    /// signal: never drop it. A `PlannedChange` with an empty vec must be
886    /// distinguishable from one with paths in it — `requires_replace()`'s
887    /// consumer (`magma-apply::engine::apply_one`) branches on exactly
888    /// this emptiness check.
889    #[test]
890    fn planned_change_requires_replace_is_empty_iff_no_paths() {
891        let no_replace = PlannedChange {
892            state: DynamicValue {
893                msgpack: vec![0xc0],
894            },
895            requires_replace: vec![],
896        };
897        let must_replace = PlannedChange {
898            state: DynamicValue {
899                msgpack: vec![0xc0],
900            },
901            requires_replace: vec!["instance_types".to_string()],
902        };
903        assert!(no_replace.requires_replace.is_empty());
904        assert!(!must_replace.requires_replace.is_empty());
905    }
906}
907
908/// The gRPC/tfplugin implementation of the provider contract.
909///
910/// Pure delegation to the inherent methods above — this adds no
911/// behaviour, it only makes the EXISTING transport one implementation of
912/// a contract rather than the only thing an engine can hold.
913///
914/// ── ★ WHY EVERY BODY IS FULLY QUALIFIED ──────────────────────────────
915/// `ProviderConn::get_schema(self)`, not `self.get_schema()`. Both
916/// resolve to the inherent method — inherent wins over trait — so the
917/// short form compiles and works today. But it is one refactor away from
918/// disaster: delete or rename the inherent method and `self.get_schema()`
919/// silently rebinds to the TRAIT method, which is this function, and the
920/// result is unbounded recursion at runtime rather than an error at
921/// compile time. The qualified form cannot rebind: if the inherent method
922/// stops existing, this stops compiling.
923#[async_trait::async_trait]
924impl Provider for ProviderConn {
925    async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
926        ProviderConn::get_schema(self).await
927    }
928
929    async fn configure(
930        &mut self,
931        config: &DynamicValue,
932        terraform_version: &str,
933    ) -> Result<(), ProviderError> {
934        ProviderConn::configure(self, config, terraform_version).await
935    }
936
937    async fn plan_resource_change(
938        &mut self,
939        type_name: &str,
940        prior_state: &DynamicValue,
941        proposed_new_state: &DynamicValue,
942        config: &DynamicValue,
943    ) -> Result<PlannedChange, ProviderError> {
944        ProviderConn::plan_resource_change(self, type_name, prior_state, proposed_new_state, config)
945            .await
946    }
947
948    async fn apply_resource_change(
949        &mut self,
950        type_name: &str,
951        prior_state: &DynamicValue,
952        planned_state: &DynamicValue,
953        config: &DynamicValue,
954    ) -> Result<DynamicValue, ProviderError> {
955        ProviderConn::apply_resource_change(self, type_name, prior_state, planned_state, config)
956            .await
957    }
958
959    async fn read_resource(
960        &mut self,
961        type_name: &str,
962        current_state: &DynamicValue,
963    ) -> Result<Option<DynamicValue>, ProviderError> {
964        ProviderConn::read_resource(self, type_name, current_state).await
965    }
966
967    async fn read_data_source(
968        &mut self,
969        type_name: &str,
970        config: &DynamicValue,
971    ) -> Result<Option<DynamicValue>, ProviderError> {
972        ProviderConn::read_data_source(self, type_name, config).await
973    }
974
975    async fn import_resource_state(
976        &mut self,
977        type_name: &str,
978        id: &str,
979    ) -> Result<Option<DynamicValue>, ProviderError> {
980        ProviderConn::import_resource_state(self, type_name, id).await
981    }
982
983    async fn upgrade_resource_state(
984        &mut self,
985        type_name: &str,
986        stored_version: i64,
987        raw_json: &[u8],
988    ) -> Result<DynamicValue, ProviderError> {
989        ProviderConn::upgrade_resource_state(self, type_name, stored_version, raw_json).await
990    }
991}