use std::collections::BTreeMap;
use magma_cty::{CtyType, DynamicValue};
use magma_protocol::{PluginProtocol, tfplugin5, tfplugin6};
use crate::H2Channel;
use crate::schema::{self, SchemaError};
type Client5 = tfplugin5::provider_client::ProviderClient<H2Channel>;
type Client6 = tfplugin6::provider_client::ProviderClient<H2Channel>;
enum Client {
V5(Client5),
V6(Client6),
}
pub(crate) fn client_caps_v6() -> Option<tfplugin6::ClientCapabilities> {
Some(tfplugin6::ClientCapabilities {
deferral_allowed: false,
write_only_attributes_allowed: false,
})
}
fn client_caps_v5() -> Option<tfplugin5::ClientCapabilities> {
Some(tfplugin5::ClientCapabilities {
deferral_allowed: false,
write_only_attributes_allowed: false,
})
}
pub struct ProviderConn {
client: Client,
}
#[derive(Debug, Clone)]
pub struct ProviderSchema {
pub provider_config: CtyType,
pub resources: BTreeMap<String, CtyType>,
pub data_sources: BTreeMap<String, CtyType>,
pub resource_versions: BTreeMap<String, i64>,
}
impl ProviderSchema {
pub fn resource(&self, type_name: &str) -> Option<&CtyType> {
self.resources.get(type_name)
}
pub fn data_source(&self, type_name: &str) -> Option<&CtyType> {
self.data_sources.get(type_name)
}
#[must_use]
pub fn resource_version(&self, type_name: &str) -> i64 {
self.resource_versions.get(type_name).copied().unwrap_or(0)
}
#[must_use]
pub fn resource_version_u64(&self, type_name: &str) -> u64 {
u64::try_from(self.resource_version(type_name)).unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diag {
pub severity: Severity,
pub summary: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedChange {
pub state: DynamicValue,
pub requires_replace: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Unknown,
}
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("provider RPC transport: {0}")]
Transport(String),
#[error("provider returned {} error diagnostic(s): {}", .0.len(), fmt_diags(.0))]
Diagnostics(Vec<Diag>),
#[error("provider returned no new_state from apply")]
NoNewState,
#[error("provider returned {} error diagnostic(s) WITH a new_state (partial apply — resource is committed): {}", .diags.len(), fmt_diags(.diags))]
PartiallyApplied {
diags: Vec<Diag>,
state: Box<DynamicValue>,
},
#[error("schema: {0}")]
Schema(#[from] SchemaError),
}
#[must_use]
pub fn is_retryable(e: &ProviderError) -> bool {
const TRANSIENT: &[&str] = &[
"rate limit",
"secondary rate",
"too many request",
"abuse",
"quota",
"try again",
"retry",
"429",
"503",
"resource_exhausted",
"unavailable",
"timeout",
"timed out",
"connection reset",
"broken pipe",
"tls",
"transport",
"h2 protocol",
"eof",
];
let hit = |s: &str| {
let l = s.to_ascii_lowercase();
TRANSIENT.iter().any(|p| l.contains(p))
};
match e {
ProviderError::Transport(s) => hit(s),
ProviderError::Diagnostics(diags) => {
diags.iter().any(|d| hit(&d.summary) || hit(&d.detail))
}
ProviderError::PartiallyApplied { .. } => false,
ProviderError::NoNewState | ProviderError::Schema(_) => false,
}
}
fn fmt_diags(diags: &[Diag]) -> String {
diags
.iter()
.map(|d| format!("{}: {}", d.summary, d.detail))
.collect::<Vec<_>>()
.join("; ")
}
impl ProviderConn {
pub fn new(channel: H2Channel, protocol: PluginProtocol) -> Self {
let client = match protocol {
PluginProtocol::V5 => {
Client::V5(Client5::new(channel).max_decoding_message_size(256 * 1024 * 1024))
}
PluginProtocol::V6 => {
Client::V6(Client6::new(channel).max_decoding_message_size(256 * 1024 * 1024))
}
};
Self { client }
}
pub async fn get_schema(&mut self) -> Result<ProviderSchema, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.get_provider_schema(tfplugin6::get_provider_schema::Request::default())
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))?;
let provider_config = match resp.provider.and_then(|s| s.block) {
Some(b) => schema::block_implied_type(&b)?,
None => CtyType::Object(BTreeMap::new()),
};
let mut resources = BTreeMap::new();
let mut resource_versions = BTreeMap::new();
for (name, sch) in resp.resource_schemas {
resource_versions.insert(name.clone(), sch.version);
if let Some(b) = sch.block {
resources.insert(name, schema::block_implied_type(&b)?);
}
}
let mut data_sources = BTreeMap::new();
for (name, sch) in resp.data_source_schemas {
if let Some(b) = sch.block {
data_sources.insert(name, schema::block_implied_type(&b)?);
}
}
Ok(ProviderSchema {
provider_config,
resources,
data_sources,
resource_versions,
})
}
Client::V5(c) => {
let resp = c
.get_schema(tfplugin5::get_provider_schema::Request::default())
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))?;
let provider_config = match resp.provider.and_then(|s| s.block) {
Some(b) => schema::block5_implied_type(&b)?,
None => CtyType::Object(BTreeMap::new()),
};
let mut resources = BTreeMap::new();
let mut resource_versions = BTreeMap::new();
for (name, sch) in resp.resource_schemas {
resource_versions.insert(name.clone(), sch.version);
if let Some(b) = sch.block {
resources.insert(name, schema::block5_implied_type(&b)?);
}
}
let mut data_sources = BTreeMap::new();
for (name, sch) in resp.data_source_schemas {
if let Some(b) = sch.block {
data_sources.insert(name, schema::block5_implied_type(&b)?);
}
}
Ok(ProviderSchema {
provider_config,
resources,
data_sources,
resource_versions,
})
}
}
}
pub async fn configure(
&mut self,
config: &DynamicValue,
terraform_version: &str,
) -> Result<(), ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.configure_provider(tfplugin6::configure_provider::Request {
terraform_version: terraform_version.to_string(),
config: Some(to_pb6(config)),
client_capabilities: client_caps_v6(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))
}
Client::V5(c) => {
let resp = c
.configure(tfplugin5::configure::Request {
terraform_version: terraform_version.to_string(),
config: Some(to_pb5(config)),
client_capabilities: client_caps_v5(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))
}
}
}
pub async fn plan_resource_change(
&mut self,
type_name: &str,
prior_state: &DynamicValue,
proposed_new_state: &DynamicValue,
config: &DynamicValue,
) -> Result<PlannedChange, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.plan_resource_change(tfplugin6::plan_resource_change::Request {
type_name: type_name.to_string(),
prior_state: Some(to_pb6(prior_state)),
proposed_new_state: Some(to_pb6(proposed_new_state)),
config: Some(to_pb6(config)),
client_capabilities: client_caps_v6(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))?;
let requires_replace = resp
.requires_replace
.iter()
.map(attribute_path_to_string_v6)
.collect();
let state = resp
.planned_state
.map(from_pb6)
.ok_or(ProviderError::NoNewState)?;
Ok(PlannedChange {
state,
requires_replace,
})
}
Client::V5(c) => {
let resp = c
.plan_resource_change(tfplugin5::plan_resource_change::Request {
type_name: type_name.to_string(),
prior_state: Some(to_pb5(prior_state)),
proposed_new_state: Some(to_pb5(proposed_new_state)),
config: Some(to_pb5(config)),
client_capabilities: client_caps_v5(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))?;
let requires_replace = resp
.requires_replace
.iter()
.map(attribute_path_to_string_v5)
.collect();
let state = resp
.planned_state
.map(from_pb5)
.ok_or(ProviderError::NoNewState)?;
Ok(PlannedChange {
state,
requires_replace,
})
}
}
}
pub async fn apply_resource_change(
&mut self,
type_name: &str,
prior_state: &DynamicValue,
planned_state: &DynamicValue,
config: &DynamicValue,
) -> Result<DynamicValue, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.apply_resource_change(tfplugin6::apply_resource_change::Request {
type_name: type_name.to_string(),
prior_state: Some(to_pb6(prior_state)),
planned_state: Some(to_pb6(planned_state)),
config: Some(to_pb6(config)),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
apply_outcome(
error_diags(resp.diagnostics.iter().map(diag6)),
resp.new_state.map(from_pb6),
)
}
Client::V5(c) => {
let resp = c
.apply_resource_change(tfplugin5::apply_resource_change::Request {
type_name: type_name.to_string(),
prior_state: Some(to_pb5(prior_state)),
planned_state: Some(to_pb5(planned_state)),
config: Some(to_pb5(config)),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
apply_outcome(
error_diags(resp.diagnostics.iter().map(diag5)),
resp.new_state.map(from_pb5),
)
}
}
}
pub async fn read_resource(
&mut self,
type_name: &str,
current_state: &DynamicValue,
) -> Result<Option<DynamicValue>, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.read_resource(tfplugin6::read_resource::Request {
type_name: type_name.to_string(),
current_state: Some(to_pb6(current_state)),
client_capabilities: client_caps_v6(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))?;
Ok(resp.new_state.map(from_pb6).filter(|d| !d.is_null()))
}
Client::V5(c) => {
let resp = c
.read_resource(tfplugin5::read_resource::Request {
type_name: type_name.to_string(),
current_state: Some(to_pb5(current_state)),
client_capabilities: client_caps_v5(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))?;
Ok(resp.new_state.map(from_pb5).filter(|d| !d.is_null()))
}
}
}
pub async fn read_data_source(
&mut self,
type_name: &str,
config: &DynamicValue,
) -> Result<Option<DynamicValue>, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.read_data_source(tfplugin6::read_data_source::Request {
type_name: type_name.to_string(),
config: Some(to_pb6(config)),
client_capabilities: client_caps_v6(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))?;
Ok(resp.state.map(from_pb6).filter(|d| !d.is_null()))
}
Client::V5(c) => {
let resp = c
.read_data_source(tfplugin5::read_data_source::Request {
type_name: type_name.to_string(),
config: Some(to_pb5(config)),
client_capabilities: client_caps_v5(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))?;
Ok(resp.state.map(from_pb5).filter(|d| !d.is_null()))
}
}
}
pub async fn import_resource_state(
&mut self,
type_name: &str,
id: &str,
) -> Result<Option<DynamicValue>, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.import_resource_state(tfplugin6::import_resource_state::Request {
type_name: type_name.to_string(),
id: id.to_string(),
client_capabilities: client_caps_v6(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))?;
Ok(resp
.imported_resources
.into_iter()
.next()
.and_then(|ir| ir.state)
.map(from_pb6)
.filter(|d| !d.is_null()))
}
Client::V5(c) => {
let resp = c
.import_resource_state(tfplugin5::import_resource_state::Request {
type_name: type_name.to_string(),
id: id.to_string(),
client_capabilities: client_caps_v5(),
..Default::default()
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))?;
Ok(resp
.imported_resources
.into_iter()
.next()
.and_then(|ir| ir.state)
.map(from_pb5)
.filter(|d| !d.is_null()))
}
}
}
pub async fn upgrade_resource_state(
&mut self,
type_name: &str,
stored_version: i64,
raw_json: &[u8],
) -> Result<DynamicValue, ProviderError> {
match &mut self.client {
Client::V6(c) => {
let resp = c
.upgrade_resource_state(tfplugin6::upgrade_resource_state::Request {
type_name: type_name.to_string(),
version: stored_version,
raw_state: Some(tfplugin6::RawState {
json: raw_json.to_vec(),
flatmap: Default::default(),
}),
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag6))?;
resp.upgraded_state
.map(from_pb6)
.ok_or(ProviderError::NoNewState)
}
Client::V5(c) => {
let resp = c
.upgrade_resource_state(tfplugin5::upgrade_resource_state::Request {
type_name: type_name.to_string(),
version: stored_version,
raw_state: Some(tfplugin5::RawState {
json: raw_json.to_vec(),
flatmap: Default::default(),
}),
})
.await
.map_err(transport)?
.into_inner();
check_diags(resp.diagnostics.iter().map(diag5))?;
resp.upgraded_state
.map(from_pb5)
.ok_or(ProviderError::NoNewState)
}
}
}
}
fn transport(s: tonic::Status) -> ProviderError {
ProviderError::Transport(s.to_string())
}
fn to_pb6(dv: &DynamicValue) -> tfplugin6::DynamicValue {
tfplugin6::DynamicValue {
msgpack: dv.msgpack.clone(),
json: Vec::new(),
}
}
fn from_pb6(dv: tfplugin6::DynamicValue) -> DynamicValue {
DynamicValue {
msgpack: dv.msgpack,
}
}
fn to_pb5(dv: &DynamicValue) -> tfplugin5::DynamicValue {
tfplugin5::DynamicValue {
msgpack: dv.msgpack.clone(),
json: Vec::new(),
}
}
fn from_pb5(dv: tfplugin5::DynamicValue) -> DynamicValue {
DynamicValue {
msgpack: dv.msgpack,
}
}
enum PathStep {
Attribute(String),
ElementKeyString(String),
ElementKeyInt(i64),
}
fn render_attribute_path(steps: impl Iterator<Item = PathStep>) -> String {
let mut out = String::new();
for step in steps {
match step {
PathStep::Attribute(name) => {
if !out.is_empty() {
out.push('.');
}
out.push_str(&name);
}
PathStep::ElementKeyString(key) => {
out.push('[');
out.push_str(&key);
out.push(']');
}
PathStep::ElementKeyInt(i) => {
out.push('[');
out.push_str(&i.to_string());
out.push(']');
}
}
}
out
}
fn attribute_path_to_string_v6(path: &tfplugin6::AttributePath) -> String {
render_attribute_path(path.steps.iter().map(|s| match &s.selector {
Some(tfplugin6::attribute_path::step::Selector::AttributeName(n)) => {
PathStep::Attribute(n.clone())
}
Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(k)) => {
PathStep::ElementKeyString(k.clone())
}
Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)) => {
PathStep::ElementKeyInt(*i)
}
None => PathStep::Attribute(String::new()),
}))
}
fn attribute_path_to_string_v5(path: &tfplugin5::AttributePath) -> String {
render_attribute_path(path.steps.iter().map(|s| match &s.selector {
Some(tfplugin5::attribute_path::step::Selector::AttributeName(n)) => {
PathStep::Attribute(n.clone())
}
Some(tfplugin5::attribute_path::step::Selector::ElementKeyString(k)) => {
PathStep::ElementKeyString(k.clone())
}
Some(tfplugin5::attribute_path::step::Selector::ElementKeyInt(i)) => {
PathStep::ElementKeyInt(*i)
}
None => PathStep::Attribute(String::new()),
}))
}
fn diag6(d: &tfplugin6::Diagnostic) -> (i32, String, String) {
(d.severity, d.summary.clone(), d.detail.clone())
}
fn diag5(d: &tfplugin5::Diagnostic) -> (i32, String, String) {
(d.severity, d.summary.clone(), d.detail.clone())
}
fn error_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Vec<Diag> {
diags
.filter(|(sev, _, _)| *sev == 1)
.map(|(_, summary, detail)| Diag {
severity: Severity::Error,
summary,
detail,
})
.collect()
}
fn apply_outcome(
errs: Vec<Diag>,
new_state: Option<DynamicValue>,
) -> Result<DynamicValue, ProviderError> {
match (errs.is_empty(), new_state) {
(true, Some(dv)) => Ok(dv),
(true, None) => Err(ProviderError::NoNewState),
(false, Some(dv)) => Err(ProviderError::PartiallyApplied {
diags: errs,
state: Box::new(dv),
}),
(false, None) => Err(ProviderError::Diagnostics(errs)),
}
}
fn check_diags(diags: impl Iterator<Item = (i32, String, String)>) -> Result<(), ProviderError> {
let errors = error_diags(diags);
if errors.is_empty() {
Ok(())
} else {
Err(ProviderError::Diagnostics(errors))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn err_diag(msg: &str) -> Vec<Diag> {
vec![Diag {
severity: Severity::Error,
summary: msg.to_string(),
detail: String::new(),
}]
}
fn eip_type() -> CtyType {
CtyType::Object(BTreeMap::from([("id".to_string(), CtyType::String)]))
}
fn some_state() -> DynamicValue {
DynamicValue::from_json(&serde_json::json!({"id": "eipalloc-1"}), &eip_type())
.expect("test fixture must encode")
}
#[test]
fn error_with_new_state_is_partially_applied_and_keeps_the_state() {
let out = apply_outcome(err_diag("tagging failed"), Some(some_state()));
match out {
Err(ProviderError::PartiallyApplied { diags, state }) => {
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].summary, "tagging failed");
let attrs = state
.to_json(&eip_type())
.expect("partial state must decode");
assert_eq!(attrs["id"], "eipalloc-1");
}
other => panic!("expected PartiallyApplied, got {other:?}"),
}
}
#[test]
fn error_without_new_state_stays_plain_diagnostics() {
assert!(matches!(
apply_outcome(err_diag("boom"), None),
Err(ProviderError::Diagnostics(_))
));
}
#[test]
fn clean_apply_with_state_is_ok() {
assert!(apply_outcome(Vec::new(), Some(some_state())).is_ok());
}
#[test]
fn clean_apply_without_state_is_no_new_state() {
assert!(matches!(
apply_outcome(Vec::new(), None),
Err(ProviderError::NoNewState)
));
}
#[test]
fn a_partial_apply_is_never_retryable_even_when_the_text_looks_transient() {
let e = ProviderError::PartiallyApplied {
diags: err_diag("connection reset by peer: timeout"),
state: Box::new(some_state()),
};
assert!(
!is_retryable(&e),
"retrying a committed resource duplicates it"
);
}
#[test]
fn empty_diagnostics_is_ok() {
assert!(check_diags(std::iter::empty()).is_ok());
}
#[test]
fn warning_only_is_ok() {
let diags = vec![(2, "heads up".to_string(), String::new())];
assert!(check_diags(diags.into_iter()).is_ok());
}
#[test]
fn any_error_diagnostic_fails() {
let diags = vec![
(2, "warn".to_string(), String::new()),
(1, "boom".to_string(), "bad".to_string()),
];
match check_diags(diags.into_iter()) {
Err(ProviderError::Diagnostics(errs)) => {
assert_eq!(errs.len(), 1, "only error-severity diags are fatal");
assert_eq!(errs[0].summary, "boom");
}
other => panic!("expected Diagnostics error, got {other:?}"),
}
}
#[test]
fn dynamic_value_pb_roundtrip_both_protocols() {
let dv = DynamicValue {
msgpack: vec![0xc0, 0x01, 0x02],
};
assert_eq!(from_pb6(to_pb6(&dv)), dv);
assert_eq!(from_pb5(to_pb5(&dv)), dv);
assert!(to_pb6(&dv).json.is_empty());
}
fn v6_attr_step(name: &str) -> tfplugin6::attribute_path::Step {
tfplugin6::attribute_path::Step {
selector: Some(tfplugin6::attribute_path::step::Selector::AttributeName(
name.to_string(),
)),
}
}
fn v6_index_step(i: i64) -> tfplugin6::attribute_path::Step {
tfplugin6::attribute_path::Step {
selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyInt(i)),
}
}
fn v6_key_step(k: &str) -> tfplugin6::attribute_path::Step {
tfplugin6::attribute_path::Step {
selector: Some(tfplugin6::attribute_path::step::Selector::ElementKeyString(
k.to_string(),
)),
}
}
#[test]
fn attribute_path_to_string_v6_single_attribute() {
let path = tfplugin6::AttributePath {
steps: vec![v6_attr_step("instance_types")],
};
assert_eq!(attribute_path_to_string_v6(&path), "instance_types");
}
#[test]
fn attribute_path_to_string_v6_nested_key() {
let path = tfplugin6::AttributePath {
steps: vec![v6_attr_step("tags"), v6_key_step("Name")],
};
assert_eq!(attribute_path_to_string_v6(&path), "tags[Name]");
}
#[test]
fn attribute_path_to_string_v6_indexed_then_attribute() {
let path = tfplugin6::AttributePath {
steps: vec![
v6_attr_step("rules"),
v6_index_step(2),
v6_attr_step("port"),
],
};
assert_eq!(attribute_path_to_string_v6(&path), "rules[2].port");
}
fn v5_attr_step(name: &str) -> tfplugin5::attribute_path::Step {
tfplugin5::attribute_path::Step {
selector: Some(tfplugin5::attribute_path::step::Selector::AttributeName(
name.to_string(),
)),
}
}
#[test]
fn attribute_path_to_string_v5_matches_v6_shape() {
let path = tfplugin5::AttributePath {
steps: vec![v5_attr_step("ami")],
};
assert_eq!(attribute_path_to_string_v5(&path), "ami");
}
#[test]
fn planned_change_requires_replace_is_empty_iff_no_paths() {
let no_replace = PlannedChange {
state: DynamicValue {
msgpack: vec![0xc0],
},
requires_replace: vec![],
};
let must_replace = PlannedChange {
state: DynamicValue {
msgpack: vec![0xc0],
},
requires_replace: vec!["instance_types".to_string()],
};
assert!(no_replace.requires_replace.is_empty());
assert!(!must_replace.requires_replace.is_empty());
}
}