use std::{
fmt::Write as _,
fs::{self, File, OpenOptions},
io::BufWriter,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use arrow::{
array::{
ArrayRef, ListBuilder, RecordBatch, StringBuilder, TimestampMillisecondBuilder,
UInt32Builder,
},
datatypes::{DataType, Field, Schema},
};
use parquet::{
arrow::ArrowWriter,
basic::{Compression, ZstdLevel},
file::properties::WriterProperties,
};
use serde::{Deserialize, Serialize};
use tracing::{info_span, instrument};
use typed_builder::TypedBuilder;
use super::{
ExportError, PARSER_VERSION, SCHEMA_MAJOR, SCHEMA_MINOR,
json::render_attribute_map,
manifest::{Manifest, ManifestFile, write_manifest},
schema::resources_schema,
};
use crate::ir::{
AttributeMap, Component, Expression, Local, ModuleCall, Output, ProviderBlock, ProviderRef,
Resource, ResourceKind, Span, Value, Variable, Workspace,
};
#[derive(Clone, Debug, PartialEq, Eq, TypedBuilder)]
#[non_exhaustive]
#[builder(field_defaults(setter(into)))]
pub struct ExportOptions {
pub out_dir: Arc<Path>,
#[builder(default = 131_072)]
pub row_group_rows: usize,
#[builder(default = 64 * 1024 * 1024)]
pub row_group_bytes: usize,
#[builder(default = CompressionOpt::Zstd(3))]
pub compression: CompressionOpt,
#[builder(default = false)]
pub overwrite: bool,
#[builder(default)]
pub parsed_at_ms: Option<i64>,
#[builder(default = Arc::from(""))]
pub command_line: Arc<str>,
#[builder(default)]
pub tables: Vec<SecondaryTable>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum CompressionOpt {
Uncompressed,
Zstd(i32),
Snappy,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum SecondaryTable {
Dependencies,
Components,
Modules,
}
impl SecondaryTable {
#[must_use]
pub const fn file_stem(self) -> &'static str {
match self {
Self::Dependencies => "dependencies",
Self::Components => "components",
Self::Modules => "modules",
}
}
}
impl CompressionOpt {
pub fn zstd(level: i32) -> Result<Self, crate::ValidationError> {
if !(1..=22).contains(&level) {
return Err(crate::ValidationError::Range {
field: "CompressionOpt::Zstd.level",
min: 1,
max: 22,
got: i64::from(level),
});
}
Ok(Self::Zstd(level))
}
fn to_parquet(self) -> Compression {
match self {
Self::Uncompressed => Compression::UNCOMPRESSED,
Self::Zstd(level) => Compression::ZSTD(ZstdLevel::try_new(level).unwrap_or_default()),
Self::Snappy => Compression::SNAPPY,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ExportedFile {
pub path: Arc<Path>,
pub rows: u64,
pub bytes: u64,
pub sha256: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ExportReport {
pub files: Vec<ExportedFile>,
pub total_rows: u64,
pub bytes_written: u64,
pub elapsed: Duration,
}
pub trait Exporter: Send + Sync {
fn export(&self, ws: &Workspace, opts: &ExportOptions) -> Result<ExportReport, ExportError>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ParquetExporter;
impl ParquetExporter {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Exporter for ParquetExporter {
#[instrument(level = "info", skip_all, fields(out = %opts.out_dir.display()))]
#[allow(clippy::too_many_lines)] fn export(&self, ws: &Workspace, opts: &ExportOptions) -> Result<ExportReport, ExportError> {
let started = std::time::Instant::now();
validate_out_dir(&opts.out_dir)?;
let final_path: Arc<Path> = Arc::from(opts.out_dir.join("resources.parquet"));
let manifest_path: Arc<Path> = Arc::from(opts.out_dir.join("workspace.manifest.json"));
let mut secondary_paths: Vec<(SecondaryTable, Arc<Path>)> =
Vec::with_capacity(opts.tables.len());
for table in &opts.tables {
let path: Arc<Path> =
Arc::from(opts.out_dir.join(format!("{}.parquet", table.file_stem())));
secondary_paths.push((*table, path));
}
if !opts.overwrite {
for p in [&final_path, &manifest_path] {
if p.exists() {
return Err(ExportError::OutputExists(Arc::clone(p)));
}
}
for (_, p) in &secondary_paths {
if p.exists() {
return Err(ExportError::OutputExists(Arc::clone(p)));
}
}
}
let parsed_at_ms = opts
.parsed_at_ms
.unwrap_or_else(|| jiff::Timestamp::now().as_millisecond());
let projected_rows = projected_row_count(ws);
let (rows, bytes) = {
let span = info_span!("write_resources", path = %final_path.display());
let _entered = span.enter();
write_resources_parquet(ws, opts, &final_path, projected_rows, parsed_at_ms)?
};
let mut secondary_outputs: Vec<(SecondaryTable, Arc<Path>, u64, u64, String)> =
Vec::with_capacity(secondary_paths.len());
for (table, path) in &secondary_paths {
let (table_rows, table_bytes) = match table {
SecondaryTable::Dependencies => {
super::secondary::write_dependencies_parquet(ws, path, opts.compression)?
}
SecondaryTable::Components => {
super::secondary::write_components_parquet(ws, path, opts.compression)?
}
SecondaryTable::Modules => {
super::secondary::write_modules_parquet(ws, path, opts.compression)?
}
};
let sha = sha256_hex_of_file(path)?;
secondary_outputs.push((*table, Arc::clone(path), table_rows, table_bytes, sha));
}
let resources_sha = sha256_hex_of_file(&final_path)?;
let mut manifest_files: Vec<ManifestFile> = Vec::with_capacity(1 + secondary_outputs.len());
manifest_files.push(ManifestFile {
name: "resources.parquet".to_string(),
rows,
bytes,
sha256: resources_sha.clone(),
});
for (table, _, r, b, sha) in &secondary_outputs {
manifest_files.push(ManifestFile {
name: format!("{}.parquet", table.file_stem()),
rows: *r,
bytes: *b,
sha256: sha.clone(),
});
}
let manifest = Manifest {
tfparser_version: PARSER_VERSION.to_string(),
schema_major: SCHEMA_MAJOR,
schema_minor: SCHEMA_MINOR,
generated_at_ms: parsed_at_ms,
workspace_root: ws.root.display().to_string(),
command_line: opts.command_line.to_string(),
files: manifest_files,
};
let manifest_bytes_written = write_manifest(&manifest, &manifest_path, opts.overwrite)?;
let manifest_sha = sha256_hex_of_file(&manifest_path)?;
let mut files: Vec<ExportedFile> = Vec::with_capacity(2 + secondary_outputs.len());
files.push(ExportedFile {
path: Arc::clone(&final_path),
rows,
bytes,
sha256: resources_sha,
});
for (_, path, table_rows, table_bytes, sha) in secondary_outputs.iter().cloned() {
files.push(ExportedFile {
path,
rows: table_rows,
bytes: table_bytes,
sha256: sha,
});
}
files.push(ExportedFile {
path: Arc::clone(&manifest_path),
rows: 0,
bytes: manifest_bytes_written,
sha256: manifest_sha,
});
let total_rows = rows
+ secondary_outputs
.iter()
.map(|(_, _, r, _, _)| *r)
.sum::<u64>();
let bytes_written = bytes
+ secondary_outputs
.iter()
.map(|(_, _, _, b, _)| *b)
.sum::<u64>()
+ manifest_bytes_written;
Ok(ExportReport {
files,
total_rows,
bytes_written,
elapsed: started.elapsed(),
})
}
}
fn validate_out_dir(out: &Path) -> Result<(), ExportError> {
if !out.exists() {
return Err(ExportError::OutDirMissing(Arc::from(out)));
}
if !out.is_dir() {
return Err(ExportError::OutDirNotDir(Arc::from(out)));
}
Ok(())
}
struct RowBuilders {
workspace_root: StringBuilder,
component_path: StringBuilder,
module_path: StringBuilder,
address: StringBuilder,
kind: StringBuilder,
resource_type: StringBuilder,
resource_name: StringBuilder,
provider_local: StringBuilder,
provider_source: StringBuilder,
account_id: StringBuilder,
account_name: StringBuilder,
region: StringBuilder,
environment: StringBuilder,
count_expr: StringBuilder,
for_each_expr: StringBuilder,
depends_on: ListBuilder<StringBuilder>,
attributes_json: StringBuilder,
state_account_id: StringBuilder,
state_region: StringBuilder,
file: StringBuilder,
line: UInt32Builder,
column: UInt32Builder,
parser_version: StringBuilder,
parsed_at: TimestampMillisecondBuilder,
schema: Arc<Schema>,
row_count: usize,
approx_bytes: usize,
}
impl RowBuilders {
fn with_capacity(rows: usize, schema: Arc<Schema>) -> Self {
Self {
workspace_root: StringBuilder::with_capacity(rows, rows * 64),
component_path: StringBuilder::with_capacity(rows, rows * 32),
module_path: StringBuilder::with_capacity(rows, rows * 16),
address: StringBuilder::with_capacity(rows, rows * 48),
kind: StringBuilder::with_capacity(rows, rows * 8),
resource_type: StringBuilder::with_capacity(rows, rows * 24),
resource_name: StringBuilder::with_capacity(rows, rows * 24),
provider_local: StringBuilder::with_capacity(rows, rows * 12),
provider_source: StringBuilder::with_capacity(rows, rows * 32),
account_id: StringBuilder::with_capacity(rows, rows * 12),
account_name: StringBuilder::with_capacity(rows, rows * 16),
region: StringBuilder::with_capacity(rows, rows * 12),
environment: StringBuilder::with_capacity(rows, rows * 12),
count_expr: StringBuilder::with_capacity(rows, rows * 16),
for_each_expr: StringBuilder::with_capacity(rows, rows * 16),
depends_on: ListBuilder::with_capacity(StringBuilder::new(), rows)
.with_field(Arc::new(Field::new("item", DataType::Utf8, false))),
attributes_json: StringBuilder::with_capacity(rows, rows * 256),
state_account_id: StringBuilder::with_capacity(rows, rows * 12),
state_region: StringBuilder::with_capacity(rows, rows * 12),
file: StringBuilder::with_capacity(rows, rows * 48),
line: UInt32Builder::with_capacity(rows),
column: UInt32Builder::with_capacity(rows),
parser_version: StringBuilder::with_capacity(rows, rows * 8),
parsed_at: TimestampMillisecondBuilder::with_capacity(rows)
.with_timezone(Arc::<str>::from("UTC")),
schema,
row_count: 0,
approx_bytes: 0,
}
}
fn append_row(&mut self, row: &Row<'_>, parsed_at_ms: i64) {
self.workspace_root.append_value(row.workspace_root);
self.component_path.append_value(row.component_path);
self.module_path.append_value(row.module_path);
self.address.append_value(row.address);
self.kind.append_value(row.kind);
self.resource_type.append_value(row.resource_type);
self.resource_name.append_value(row.resource_name);
self.provider_local.append_value(row.provider_local);
self.provider_source.append_value(row.provider_source);
self.account_id.append_value(row.account_id);
self.account_name.append_value(row.account_name);
self.region.append_value(row.region);
self.environment.append_value(row.environment);
self.count_expr.append_value(row.count_expr);
self.for_each_expr.append_value(row.for_each_expr);
let inner = self.depends_on.values();
for dep in row.depends_on {
inner.append_value(dep);
}
self.depends_on.append(true);
self.attributes_json.append_value(row.attributes_json);
self.state_account_id.append_value(row.state_account_id);
self.state_region.append_value(row.state_region);
self.file.append_value(row.file);
self.line.append_value(row.line);
self.column.append_value(row.column);
self.parser_version.append_value(PARSER_VERSION);
self.parsed_at.append_value(parsed_at_ms);
self.row_count += 1;
self.approx_bytes += approx_row_bytes(row);
}
fn batch(&mut self) -> Result<RecordBatch, arrow::error::ArrowError> {
let arrays: Vec<ArrayRef> = vec![
Arc::new(self.workspace_root.finish()),
Arc::new(self.component_path.finish()),
Arc::new(self.module_path.finish()),
Arc::new(self.address.finish()),
Arc::new(self.kind.finish()),
Arc::new(self.resource_type.finish()),
Arc::new(self.resource_name.finish()),
Arc::new(self.provider_local.finish()),
Arc::new(self.provider_source.finish()),
Arc::new(self.account_id.finish()),
Arc::new(self.account_name.finish()),
Arc::new(self.region.finish()),
Arc::new(self.environment.finish()),
Arc::new(self.count_expr.finish()),
Arc::new(self.for_each_expr.finish()),
Arc::new(self.depends_on.finish()),
Arc::new(self.attributes_json.finish()),
Arc::new(self.state_account_id.finish()),
Arc::new(self.state_region.finish()),
Arc::new(self.file.finish()),
Arc::new(self.line.finish()),
Arc::new(self.column.finish()),
Arc::new(self.parser_version.finish()),
Arc::new(self.parsed_at.finish()),
];
let batch = RecordBatch::try_new(Arc::clone(&self.schema), arrays)?;
self.row_count = 0;
self.approx_bytes = 0;
Ok(batch)
}
}
struct Row<'a> {
workspace_root: &'a str,
component_path: &'a str,
module_path: &'a str,
address: &'a str,
kind: &'a str,
resource_type: &'a str,
resource_name: &'a str,
provider_local: &'a str,
provider_source: &'a str,
account_id: &'a str,
account_name: &'a str,
region: &'a str,
environment: &'a str,
count_expr: &'a str,
for_each_expr: &'a str,
depends_on: &'a [String],
attributes_json: &'a str,
state_account_id: &'a str,
state_region: &'a str,
file: &'a str,
line: u32,
column: u32,
}
fn approx_row_bytes(row: &Row<'_>) -> usize {
row.workspace_root.len()
+ row.component_path.len()
+ row.module_path.len()
+ row.address.len()
+ row.kind.len()
+ row.resource_type.len()
+ row.resource_name.len()
+ row.provider_local.len()
+ row.provider_source.len()
+ row.account_id.len()
+ row.account_name.len()
+ row.region.len()
+ row.environment.len()
+ row.count_expr.len()
+ row.for_each_expr.len()
+ row.depends_on.iter().map(String::len).sum::<usize>()
+ row.attributes_json.len()
+ row.state_account_id.len()
+ row.state_region.len()
+ row.file.len()
+ 8
}
const MAX_PREALLOC_ROWS: usize = 1_000_000;
fn projected_row_count(ws: &Workspace) -> usize {
ws.components
.iter()
.map(|c| {
c.resources.len()
+ c.providers.len()
+ c.modules.len()
+ c.outputs.len()
+ c.variables.len()
+ c.locals.len()
})
.sum::<usize>()
.min(MAX_PREALLOC_ROWS)
}
fn write_resources_parquet(
ws: &Workspace,
opts: &ExportOptions,
final_path: &Path,
projected_rows: usize,
parsed_at_ms: i64,
) -> Result<(u64, u64), ExportError> {
let partial: PathBuf = partial_path(final_path);
if partial.exists() {
fs::remove_file(&partial).map_err(|source| ExportError::Io {
path: Arc::from(partial.as_path()),
source,
})?;
}
let schema = Arc::new(resources_schema());
let mut builders = RowBuilders::with_capacity(projected_rows.max(64), Arc::clone(&schema));
let workspace_root_str = ws.root.display().to_string();
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&partial)
.map_err(|source| ExportError::Io {
path: Arc::from(partial.as_path()),
source,
})?;
let buf = BufWriter::with_capacity(256 * 1024, file);
let writer_props = WriterProperties::builder()
.set_compression(opts.compression.to_parquet())
.set_key_value_metadata(Some(vec![
parquet::file::metadata::KeyValue::new(
"tfparser.schema.major".to_string(),
Some(SCHEMA_MAJOR.to_string()),
),
parquet::file::metadata::KeyValue::new(
"tfparser.schema.minor".to_string(),
Some(SCHEMA_MINOR.to_string()),
),
parquet::file::metadata::KeyValue::new(
"tfparser.parser.version".to_string(),
Some(PARSER_VERSION.to_string()),
),
]))
.build();
let mut arrow_writer = ArrowWriter::try_new(buf, Arc::clone(&schema), Some(writer_props))
.map_err(|source| ExportError::Parquet {
path: Arc::from(partial.as_path()),
source,
})?;
let mut total_rows: u64 = 0;
let mut sorted_components: Vec<&Component> = ws.components.iter().collect();
sorted_components.sort_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
for component in sorted_components {
let component_path_str = render_path(&component.path);
emit_component_rows(
component,
&workspace_root_str,
&component_path_str,
parsed_at_ms,
&mut builders,
&mut arrow_writer,
&Arc::from(partial.as_path()),
opts,
&mut total_rows,
)?;
}
if builders.row_count > 0 {
flush_batch(&mut builders, &mut arrow_writer, &partial)?;
}
let buf = arrow_writer
.into_inner()
.map_err(|source| ExportError::Parquet {
path: Arc::from(partial.as_path()),
source,
})?;
let file = buf.into_inner().map_err(|err| ExportError::Io {
path: Arc::from(partial.as_path()),
source: err.into_error(),
})?;
file.sync_all().map_err(|source| ExportError::Io {
path: Arc::from(partial.as_path()),
source,
})?;
drop(file);
fs::rename(&partial, final_path).map_err(|source| ExportError::Io {
path: Arc::from(partial.as_path()),
source,
})?;
let bytes = fs::metadata(final_path)
.map(|m| m.len())
.map_err(|source| ExportError::Io {
path: Arc::from(final_path),
source,
})?;
Ok((total_rows, bytes))
}
fn partial_path(final_path: &Path) -> PathBuf {
let mut s: std::ffi::OsString = final_path.as_os_str().to_os_string();
s.push(".partial");
PathBuf::from(s)
}
#[allow(clippy::too_many_arguments)]
fn emit_component_rows(
component: &Component,
workspace_root_str: &str,
component_path_str: &str,
parsed_at_ms: i64,
builders: &mut RowBuilders,
arrow_writer: &mut ArrowWriter<BufWriter<File>>,
partial_path: &Arc<Path>,
opts: &ExportOptions,
total_rows: &mut u64,
) -> Result<(), ExportError> {
let mut rows: Vec<EmittedRow> = Vec::new();
let state_account_id = component
.state_backend
.as_ref()
.and_then(|b| b.state_account_id.as_ref())
.map(|a| a.as_str().to_string())
.unwrap_or_default();
let state_region = component
.state_backend
.as_ref()
.and_then(|b| b.state_region.as_ref())
.map(|r| r.as_str().to_string())
.unwrap_or_default();
for r in &component.resources {
rows.push(resource_row(r, &state_account_id, &state_region));
}
for p in &component.providers {
rows.push(provider_row(p));
}
for m in &component.modules {
rows.push(module_call_row(m));
}
for v in &component.variables {
rows.push(variable_row(v));
}
for l in &component.locals {
rows.push(local_row(l));
}
for o in &component.outputs {
rows.push(output_row(o));
}
rows.sort_by(|a, b| {
(a.module_path.as_str(), a.address.as_str())
.cmp(&(b.module_path.as_str(), b.address.as_str()))
});
let mut json_scratch = String::with_capacity(4096);
for emitted in &rows {
json_scratch.clear();
render_attribute_map(&emitted.attributes, &mut json_scratch);
let row = Row {
workspace_root: workspace_root_str,
component_path: component_path_str,
module_path: emitted.module_path.as_str(),
address: emitted.address.as_str(),
kind: emitted.kind,
resource_type: emitted.resource_type.as_str(),
resource_name: emitted.resource_name.as_str(),
provider_local: emitted.provider_local.as_str(),
provider_source: emitted.provider_source.as_str(),
account_id: emitted.account_id.as_str(),
account_name: emitted.account_name.as_str(),
region: emitted.region.as_str(),
environment: emitted.environment.as_str(),
count_expr: emitted.count_expr.as_str(),
for_each_expr: emitted.for_each_expr.as_str(),
depends_on: emitted.depends_on.as_slice(),
attributes_json: json_scratch.as_str(),
state_account_id: emitted.state_account_id.as_str(),
state_region: emitted.state_region.as_str(),
file: emitted.file.as_str(),
line: emitted.line,
column: emitted.column,
};
builders.append_row(&row, parsed_at_ms);
*total_rows = total_rows.saturating_add(1);
if builders.row_count >= opts.row_group_rows
|| builders.approx_bytes >= opts.row_group_bytes
{
flush_batch(builders, arrow_writer, partial_path)?;
}
}
Ok(())
}
fn flush_batch(
builders: &mut RowBuilders,
arrow_writer: &mut ArrowWriter<BufWriter<File>>,
partial_path: &Path,
) -> Result<(), ExportError> {
let batch = builders.batch().map_err(|source| ExportError::Arrow {
path: Arc::from(partial_path),
source,
})?;
arrow_writer
.write(&batch)
.map_err(|source| ExportError::Parquet {
path: Arc::from(partial_path),
source,
})?;
Ok(())
}
struct EmittedRow {
module_path: String,
address: String,
kind: &'static str,
resource_type: String,
resource_name: String,
provider_local: String,
provider_source: String,
account_id: String,
account_name: String,
region: String,
environment: String,
count_expr: String,
for_each_expr: String,
depends_on: Vec<String>,
attributes: AttributeMap,
state_account_id: String,
state_region: String,
file: String,
line: u32,
column: u32,
}
fn resource_row(r: &Resource, state_account_id: &str, state_region: &str) -> EmittedRow {
let provider_local = r
.provider_ref
.as_ref()
.map(provider_ref_string)
.unwrap_or_default();
let account_id = r
.account_id
.as_ref()
.map(|a| a.as_str().to_string())
.unwrap_or_default();
let account_name = r
.account_name
.as_deref()
.map(str::to_string)
.unwrap_or_default();
let region = r
.region
.as_ref()
.map(|reg| reg.as_str().to_string())
.unwrap_or_default();
EmittedRow {
module_path: r.address.module_path(),
address: r.address.as_str().to_string(),
kind: match r.kind {
ResourceKind::Managed => "resource",
ResourceKind::Data => "data",
},
resource_type: r.type_.to_string(),
resource_name: r.name.to_string(),
provider_local,
provider_source: String::new(),
account_id,
account_name,
region,
environment: String::new(),
count_expr: r
.count_expr
.as_ref()
.map(render_expression_source)
.unwrap_or_default(),
for_each_expr: r
.for_each_expr
.as_ref()
.map(render_expression_source)
.unwrap_or_default(),
depends_on: r
.depends_on
.iter()
.map(|a| a.as_str().to_string())
.collect(),
attributes: r.attributes.clone(),
state_account_id: state_account_id.to_string(),
state_region: state_region.to_string(),
file: span_relative_file(&r.span),
line: r.span.line,
column: r.span.column,
}
}
fn provider_row(p: &ProviderBlock) -> EmittedRow {
let local = p.local_name.to_string();
let provider_local = match p.alias.as_deref() {
Some(a) if !a.is_empty() => format!("{local}.{a}"),
_ => local.clone(),
};
let address = format!("provider.{provider_local}");
EmittedRow {
module_path: String::new(),
address,
kind: "provider",
resource_type: String::new(),
resource_name: provider_local.clone(),
provider_local,
provider_source: p
.source_addr
.as_deref()
.map(str::to_string)
.unwrap_or_default(),
account_id: String::new(),
account_name: String::new(),
region: String::new(),
environment: String::new(),
count_expr: String::new(),
for_each_expr: String::new(),
depends_on: Vec::new(),
attributes: p.raw.clone(),
state_account_id: String::new(),
state_region: String::new(),
file: span_relative_file(&p.span),
line: p.span.line,
column: p.span.column,
}
}
fn module_call_row(m: &ModuleCall) -> EmittedRow {
let attrs: AttributeMap = m.inputs.clone();
let provider_local = m
.providers
.first()
.map(|(_, r)| provider_ref_string(r))
.unwrap_or_default();
EmittedRow {
module_path: m.address.module_path(),
address: m.address.as_str().to_string(),
kind: "module",
resource_type: String::new(),
resource_name: m
.address
.as_str()
.strip_prefix("module.")
.map_or_else(|| m.address.as_str().to_string(), str::to_string),
provider_local,
provider_source: m.source_raw.to_string(),
account_id: String::new(),
account_name: String::new(),
region: String::new(),
environment: String::new(),
count_expr: m
.count_expr
.as_ref()
.map(render_expression_source)
.unwrap_or_default(),
for_each_expr: m
.for_each_expr
.as_ref()
.map(render_expression_source)
.unwrap_or_default(),
depends_on: Vec::new(),
attributes: attrs,
state_account_id: String::new(),
state_region: String::new(),
file: span_relative_file(&m.span),
line: m.span.line,
column: m.span.column,
}
}
fn variable_row(v: &Variable) -> EmittedRow {
let mut attrs: AttributeMap = Vec::new();
if let Some(t) = &v.type_expr {
attrs.push((Arc::from("type"), t.clone()));
}
if let Some(d) = &v.default {
attrs.push((Arc::from("default"), d.clone()));
}
if let Some(d) = &v.description {
attrs.push((
Arc::from("description"),
Expression::Literal(Value::Str(Arc::clone(d))),
));
}
attrs.push((
Arc::from("sensitive"),
Expression::Literal(Value::Bool(v.sensitive)),
));
EmittedRow {
module_path: String::new(),
address: format!("var.{}", v.name),
kind: "variable",
resource_type: String::new(),
resource_name: v.name.to_string(),
provider_local: String::new(),
provider_source: String::new(),
account_id: String::new(),
account_name: String::new(),
region: String::new(),
environment: String::new(),
count_expr: String::new(),
for_each_expr: String::new(),
depends_on: Vec::new(),
attributes: attrs,
state_account_id: String::new(),
state_region: String::new(),
file: span_relative_file(&v.span),
line: v.span.line,
column: v.span.column,
}
}
fn local_row(l: &Local) -> EmittedRow {
let attrs: AttributeMap = vec![(Arc::from("value"), l.value.clone())];
EmittedRow {
module_path: String::new(),
address: format!("local.{}", l.name),
kind: "local",
resource_type: String::new(),
resource_name: l.name.to_string(),
provider_local: String::new(),
provider_source: String::new(),
account_id: String::new(),
account_name: String::new(),
region: String::new(),
environment: String::new(),
count_expr: String::new(),
for_each_expr: String::new(),
depends_on: Vec::new(),
attributes: attrs,
state_account_id: String::new(),
state_region: String::new(),
file: span_relative_file(&l.span),
line: l.span.line,
column: l.span.column,
}
}
fn output_row(o: &Output) -> EmittedRow {
let mut attrs: AttributeMap = Vec::new();
attrs.push((Arc::from("value"), o.value.clone()));
if let Some(d) = &o.description {
attrs.push((
Arc::from("description"),
Expression::Literal(Value::Str(Arc::clone(d))),
));
}
attrs.push((
Arc::from("sensitive"),
Expression::Literal(Value::Bool(o.sensitive)),
));
EmittedRow {
module_path: String::new(),
address: format!("output.{}", o.name),
kind: "output",
resource_type: String::new(),
resource_name: o.name.to_string(),
provider_local: String::new(),
provider_source: String::new(),
account_id: String::new(),
account_name: String::new(),
region: String::new(),
environment: String::new(),
count_expr: String::new(),
for_each_expr: String::new(),
depends_on: Vec::new(),
attributes: attrs,
state_account_id: String::new(),
state_region: String::new(),
file: span_relative_file(&o.span),
line: o.span.line,
column: o.span.column,
}
}
fn provider_ref_string(r: &ProviderRef) -> String {
match r.alias.as_deref() {
Some(a) if !a.is_empty() => format!("{}.{a}", r.local_name),
_ => r.local_name.to_string(),
}
}
fn render_path(p: &Path) -> String {
let mut out = String::with_capacity(p.as_os_str().len());
for (idx, comp) in p.components().enumerate() {
if idx > 0 {
out.push('/');
}
match comp {
std::path::Component::Normal(s) => {
out.push_str(&s.to_string_lossy());
}
std::path::Component::ParentDir => out.push_str(".."),
std::path::Component::CurDir => out.push('.'),
std::path::Component::Prefix(_) | std::path::Component::RootDir => {
out.push_str(&comp.as_os_str().to_string_lossy());
}
}
}
out
}
fn span_relative_file(span: &Span) -> String {
render_path(&span.file)
}
fn render_expression_source(expr: &Expression) -> String {
match expr {
Expression::Literal(Value::Int(n)) => n.to_string(),
Expression::Literal(Value::Bool(b)) => b.to_string(),
Expression::Literal(Value::Str(s)) => s.to_string(),
Expression::Literal(Value::Number(f)) if f.is_finite() => {
let mut buf = ryu::Buffer::new();
buf.format(*f).to_string()
}
Expression::Unresolved(s) => s.source.to_string(),
_ => {
let mut s = String::new();
let map: AttributeMap = vec![(Arc::from(""), expr.clone())];
render_attribute_map(&map, &mut s);
s
}
}
}
fn sha256_hex_of_file(path: &Path) -> Result<String, ExportError> {
use sha2::{Digest, Sha256};
let bytes = fs::read(path).map_err(|source| ExportError::Io {
path: Arc::from(path),
source,
})?;
let mut h = Sha256::new();
h.update(&bytes);
let digest = h.finalize();
let mut out = String::with_capacity(64);
for b in digest {
let _ = write!(out, "{b:02x}");
}
Ok(out)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::ir::{
Address, ComponentId, ComponentKind, ResourceKind, Span, SymbolKind, Symbolic,
};
fn arc_path<P: AsRef<Path>>(p: P) -> Arc<Path> {
Arc::from(p.as_ref())
}
fn minimal_resource() -> Resource {
Resource::builder()
.address(Address::new("aws_iam_role.r").unwrap())
.kind(ResourceKind::Managed)
.type_(Arc::<str>::from("aws_iam_role"))
.name(Arc::<str>::from("r"))
.span(Span::synthetic())
.build()
}
fn minimal_component() -> Component {
Component::builder()
.id(ComponentId::from_index(0))
.path(arc_path(PathBuf::from("svc")))
.kind(ComponentKind::Component)
.resources(vec![minimal_resource()])
.build()
}
#[test]
fn test_should_write_resources_parquet_with_one_row() {
let tmp = tempfile::tempdir().unwrap();
let ws = Workspace::builder()
.root(arc_path(tmp.path()))
.components(vec![minimal_component()])
.build();
let opts = ExportOptions::builder()
.out_dir(arc_path(tmp.path()))
.parsed_at_ms(Some(1_700_000_000_000_i64))
.build();
let report = ParquetExporter::new().export(&ws, &opts).unwrap();
assert_eq!(report.total_rows, 1);
assert!(
report
.files
.iter()
.any(|f| f.path.file_name().and_then(|n| n.to_str()) == Some("resources.parquet"))
);
}
#[test]
fn test_should_refuse_overwrite_without_flag() {
let tmp = tempfile::tempdir().unwrap();
let final_path = tmp.path().join("resources.parquet");
fs::write(&final_path, b"sentinel").unwrap();
let ws = Workspace::builder()
.root(arc_path(tmp.path()))
.components(vec![minimal_component()])
.build();
let opts = ExportOptions::builder()
.out_dir(arc_path(tmp.path()))
.parsed_at_ms(Some(1))
.build();
let err = ParquetExporter::new().export(&ws, &opts).unwrap_err();
assert!(matches!(err, ExportError::OutputExists(_)));
}
#[test]
fn test_should_overwrite_when_flag_set() {
let tmp = tempfile::tempdir().unwrap();
let final_path = tmp.path().join("resources.parquet");
fs::write(&final_path, b"sentinel").unwrap();
let ws = Workspace::builder()
.root(arc_path(tmp.path()))
.components(vec![minimal_component()])
.build();
let opts = ExportOptions::builder()
.out_dir(arc_path(tmp.path()))
.parsed_at_ms(Some(1))
.overwrite(true)
.build();
let report = ParquetExporter::new().export(&ws, &opts).unwrap();
assert_eq!(report.total_rows, 1);
let bytes = fs::read(&final_path).unwrap();
assert_ne!(bytes, b"sentinel".to_vec());
}
#[test]
fn test_should_be_byte_deterministic_with_pinned_parsed_at() {
let tmp_a = tempfile::tempdir().unwrap();
let tmp_b = tempfile::tempdir().unwrap();
let ws_a = Workspace::builder()
.root(arc_path(tmp_a.path()))
.components(vec![minimal_component()])
.build();
let ws_b = Workspace::builder()
.root(arc_path(tmp_b.path()))
.components(vec![minimal_component()])
.build();
let opts_a = ExportOptions::builder()
.out_dir(arc_path(tmp_a.path()))
.parsed_at_ms(Some(1_700_000_000_000_i64))
.build();
let opts_b = ExportOptions::builder()
.out_dir(arc_path(tmp_b.path()))
.parsed_at_ms(Some(1_700_000_000_000_i64))
.build();
let r_a = ParquetExporter::new().export(&ws_a, &opts_a).unwrap();
let r_b = ParquetExporter::new().export(&ws_b, &opts_b).unwrap();
let parquet_a = r_a
.files
.iter()
.find(|f| f.path.file_name().and_then(|n| n.to_str()) == Some("resources.parquet"))
.unwrap();
let parquet_b = r_b
.files
.iter()
.find(|f| f.path.file_name().and_then(|n| n.to_str()) == Some("resources.parquet"))
.unwrap();
assert!(parquet_a.bytes > 0);
assert!(parquet_b.bytes > 0);
}
#[test]
fn test_partial_path_appends_suffix() {
let p = partial_path(Path::new("/tmp/resources.parquet"));
assert_eq!(p, PathBuf::from("/tmp/resources.parquet.partial"));
}
#[test]
fn test_render_path_normalises_separators() {
use std::path::PathBuf;
assert_eq!(render_path(&PathBuf::from("a/b/c.tf")), "a/b/c.tf");
assert_eq!(render_path(&PathBuf::from("main.tf")), "main.tf");
assert_eq!(render_path(&PathBuf::from("")), "");
assert_eq!(
render_path(&PathBuf::from("../foo/main.tf")),
"../foo/main.tf"
);
}
#[test]
fn test_render_expression_source_int_and_unresolved() {
assert_eq!(
render_expression_source(&Expression::Literal(Value::Int(3))),
"3"
);
let expr = Expression::Unresolved(
Symbolic::builder()
.kind(SymbolKind::Var)
.source(Arc::<str>::from("var.x"))
.span(Span::synthetic())
.build(),
);
assert_eq!(render_expression_source(&expr), "var.x");
}
#[test]
fn test_should_refuse_when_out_dir_missing() {
let ws = Workspace::builder()
.root(arc_path(PathBuf::from("/tmp/x")))
.build();
let opts = ExportOptions::builder()
.out_dir(arc_path(PathBuf::from("/this/does/not/exist/zzz")))
.parsed_at_ms(Some(0))
.build();
let err = ParquetExporter::new().export(&ws, &opts).unwrap_err();
assert!(matches!(err, ExportError::OutDirMissing(_)));
}
#[test]
fn test_should_refuse_when_out_dir_is_file() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("not-a-dir");
fs::write(&f, b"x").unwrap();
let ws = Workspace::builder().root(arc_path(tmp.path())).build();
let opts = ExportOptions::builder()
.out_dir(arc_path(f))
.parsed_at_ms(Some(0))
.build();
let err = ParquetExporter::new().export(&ws, &opts).unwrap_err();
assert!(matches!(err, ExportError::OutDirNotDir(_)));
}
}