1use std::{
2 collections::{BTreeMap, BTreeSet},
3 error::Error,
4 fs, io,
5 path::Path,
6};
7
8use cloudcover_aws::{AwsProvider, IamPermission, iam_permission_catalog};
9use cloudcover_core::{CloudProvider, Language, MethodReference, ResolvedSdk, Sdk};
10use cloudcover_terraform_provider_aws::{
11 TerraformProviderAwsMappingsLookup, provider_versions, sdk_method_mappings,
12};
13use serde::Serialize;
14use tempfile::Builder;
15
16const SCHEMA_VERSION: u8 = 1;
17const LIFECYCLE_ACTIONS: [&str; 4] = ["create", "read", "update", "delete"];
18
19type ApiRow = (String, String, Vec<usize>);
20type TerraformRow = (String, String, Vec<usize>);
21
22#[derive(Serialize)]
23struct ApiArtifact {
24 schema_version: u8,
25 iam_permissions: Vec<String>,
26 api_methods: Vec<ApiRow>,
27 fas: Vec<ApiFas>,
28}
29
30#[derive(Serialize)]
31struct ApiFas {
32 coverage: cloudcover_aws::fas::FasCoverage,
33 dependencies: Vec<cloudcover_aws::fas::FasDependency>,
34}
35
36#[derive(Serialize)]
37struct TerraformIndexArtifact {
38 schema_version: u8,
39 latest: String,
40 versions: Vec<String>,
41}
42
43#[derive(Serialize)]
44struct TerraformRowsArtifact {
45 schema_version: u8,
46 rows: Vec<TerraformRow>,
47}
48
49#[derive(Serialize)]
50struct TerraformVersionArtifact {
51 schema_version: u8,
52 version: String,
53 row_ids: Vec<usize>,
54}
55
56#[derive(Serialize)]
57struct IamArtifact {
58 schema_version: u8,
59 permissions: Vec<PermissionDetails>,
60}
61
62#[derive(Serialize)]
63struct PermissionDetails {
64 permission: &'static str,
65 resources: Vec<ResourceDetails>,
66 context_keys: Vec<ContextKeyDetails>,
67}
68
69#[derive(Serialize)]
70struct ResourceDetails {
71 name: &'static str,
72 arn_formats: &'static [&'static str],
73}
74
75#[derive(Serialize)]
76struct ContextKeyDetails {
77 name: &'static str,
78 types: &'static [&'static str],
79 permission: bool,
80 permission_resources: &'static [&'static str],
81 resource_types: &'static [&'static str],
82}
83
84impl From<&IamPermission> for PermissionDetails {
85 fn from(value: &IamPermission) -> Self {
86 Self {
87 permission: value.permission,
88 resources: value
89 .resources
90 .iter()
91 .map(|resource| ResourceDetails {
92 name: resource.name,
93 arn_formats: resource.arn_formats,
94 })
95 .collect(),
96 context_keys: value
97 .context_keys
98 .iter()
99 .map(|key| ContextKeyDetails {
100 name: key.name,
101 types: key.types,
102 permission: key.permission,
103 permission_resources: key.permission_resources,
104 resource_types: key.resource_types,
105 })
106 .collect(),
107 }
108 }
109}
110
111struct Catalog {
112 api: ApiArtifact,
113 terraform_index: TerraformIndexArtifact,
114 terraform_rows: TerraformRowsArtifact,
115 terraform_versions: Vec<TerraformVersionArtifact>,
116}
117
118pub fn generate(output_dir: &Path) -> Result<(), Box<dyn Error>> {
125 let output_exists = match fs::symlink_metadata(output_dir) {
126 Ok(metadata) => {
127 if !metadata.file_type().is_dir() {
128 return Err(io::Error::other(format!(
129 "output path is not a directory: {}",
130 output_dir.display()
131 ))
132 .into());
133 }
134 true
135 }
136 Err(error) if error.kind() == io::ErrorKind::NotFound => false,
137 Err(error) => return Err(error.into()),
138 };
139
140 let parent = output_dir.parent().unwrap_or_else(|| Path::new("."));
141 fs::create_dir_all(parent)?;
142 let staging = Builder::new()
143 .prefix(".cloudcover-site-data-")
144 .tempdir_in(parent)?;
145 let catalog = build_catalog()?;
146 write_catalog(staging.path(), &catalog)?;
147 replace_output(output_dir, parent, staging.path(), output_exists)?;
148 Ok(())
149}
150
151fn build_catalog() -> Result<Catalog, Box<dyn Error>> {
152 let provider = AwsProvider::new();
153 let mut api_methods = provider.list_api_methods();
154 api_methods.sort();
155 api_methods.dedup();
156
157 let mut permissions_by_method = Vec::with_capacity(api_methods.len());
158 let mut all_permissions = iam_permission_catalog()
159 .iter()
160 .map(|entry| entry.permission.to_owned())
161 .collect::<BTreeSet<_>>();
162 for method in &api_methods {
163 let permissions = provider.iam_permissions(method)?;
164 for permission in &permissions {
165 all_permissions.insert((*permission).to_owned());
166 }
167 permissions_by_method.push(permissions);
168 }
169
170 let iam_permissions = all_permissions.into_iter().collect::<Vec<_>>();
171 let permission_ids = iam_permissions
172 .iter()
173 .enumerate()
174 .map(|(id, permission)| (permission.as_str(), id))
175 .collect::<BTreeMap<_, _>>();
176 let api_ids = api_methods
177 .iter()
178 .enumerate()
179 .map(|(id, method)| ((method.service(), method.name()), id))
180 .collect::<BTreeMap<_, _>>();
181
182 let api_rows = api_methods
183 .iter()
184 .zip(permissions_by_method)
185 .map(|(method, permissions)| {
186 let permission_ids = permissions
187 .into_iter()
188 .map(|permission| {
189 permission_ids.get(permission).copied().ok_or_else(|| {
190 io::Error::other(format!("missing IAM permission {permission:?}"))
191 })
192 })
193 .collect::<Result<Vec<_>, _>>()?;
194 Ok((
195 method.service().to_owned(),
196 method.name().to_owned(),
197 permission_ids,
198 ))
199 })
200 .collect::<Result<Vec<_>, io::Error>>()?;
201
202 let fas = api_methods
203 .iter()
204 .map(|method| {
205 Ok(ApiFas {
206 coverage: provider.fas_coverage(method)?,
207 dependencies: provider.fas_dependencies(method)?,
208 })
209 })
210 .collect::<Result<Vec<_>, cloudcover_aws::AwsError>>()?;
211
212 let (terraform_index, terraform_rows, terraform_versions) = build_terraform_catalog(&api_ids)?;
213
214 Ok(Catalog {
215 api: ApiArtifact {
216 schema_version: SCHEMA_VERSION,
217 iam_permissions,
218 api_methods: api_rows,
219 fas,
220 },
221 terraform_index,
222 terraform_rows,
223 terraform_versions,
224 })
225}
226
227fn build_terraform_catalog(
228 api_ids: &BTreeMap<(&str, &str), usize>,
229) -> Result<
230 (
231 TerraformIndexArtifact,
232 TerraformRowsArtifact,
233 Vec<TerraformVersionArtifact>,
234 ),
235 Box<dyn Error>,
236> {
237 let mut supported_versions = Vec::new();
238 let mut version_rows = Vec::new();
239 let mut all_rows = BTreeSet::new();
240
241 for version in provider_versions() {
242 let mappings = match sdk_method_mappings(version) {
243 TerraformProviderAwsMappingsLookup::Supported(_) => AwsProvider::new()
244 .sdk_method_mappings(&ResolvedSdk::new(
245 Sdk::new(
246 cloudcover_terraform_provider_aws::SDK_NAME,
247 Language::Terraform,
248 )
249 .with_version(*version),
250 ))?,
251 TerraformProviderAwsMappingsLookup::Unsupported(_) => continue,
252 TerraformProviderAwsMappingsLookup::Unknown => {
253 return Err(io::Error::other(format!(
254 "Terraform provider AWS version {version:?} is not indexed"
255 ))
256 .into());
257 }
258 };
259
260 let mut rows = BTreeSet::new();
261 for mapping in mappings {
262 let MethodReference::Terraform(reference) = mapping.method() else {
263 return Err(io::Error::other("non-Terraform method in Terraform catalog").into());
264 };
265 if reference.kind() != "resource" || !LIFECYCLE_ACTIONS.contains(&reference.action()) {
266 continue;
267 }
268
269 let mut method_ids = mapping
270 .api_methods()
271 .iter()
272 .map(|method| {
273 api_ids
274 .get(&(method.service(), method.name()))
275 .copied()
276 .ok_or_else(|| {
277 io::Error::other(format!(
278 "missing canonical API method {}:{}",
279 method.service(),
280 method.name()
281 ))
282 })
283 })
284 .collect::<Result<Vec<_>, _>>()?;
285 method_ids.sort_unstable();
286 method_ids.dedup();
287 rows.insert((
288 reference.type_name().to_owned(),
289 reference.action().to_owned(),
290 method_ids,
291 ));
292 }
293
294 let rows = rows.into_iter().collect::<Vec<_>>();
295 all_rows.extend(rows.iter().cloned());
296 supported_versions.push((*version).to_owned());
297 version_rows.push(((*version).to_owned(), rows));
298 }
299
300 let latest = supported_versions
301 .last()
302 .cloned()
303 .ok_or_else(|| io::Error::other("no supported Terraform provider AWS versions"))?;
304 let rows = all_rows.into_iter().collect::<Vec<_>>();
305 let row_ids = rows
306 .iter()
307 .enumerate()
308 .map(|(id, row)| (row.clone(), id))
309 .collect::<BTreeMap<_, _>>();
310 let terraform_versions = version_rows
311 .into_iter()
312 .map(|(version, rows)| {
313 let mut ids = rows
314 .into_iter()
315 .map(|row| {
316 row_ids.get(&row).copied().ok_or_else(|| {
317 io::Error::other(format!("missing Terraform row for {version:?}"))
318 })
319 })
320 .collect::<Result<Vec<_>, _>>()?;
321 ids.sort_unstable();
322 ids.dedup();
323 Ok(TerraformVersionArtifact {
324 schema_version: SCHEMA_VERSION,
325 version,
326 row_ids: ids,
327 })
328 })
329 .collect::<Result<Vec<_>, io::Error>>()?;
330
331 Ok((
332 TerraformIndexArtifact {
333 schema_version: SCHEMA_VERSION,
334 latest,
335 versions: supported_versions,
336 },
337 TerraformRowsArtifact {
338 schema_version: SCHEMA_VERSION,
339 rows,
340 },
341 terraform_versions,
342 ))
343}
344
345fn write_catalog(root: &Path, catalog: &Catalog) -> Result<(), Box<dyn Error>> {
346 write_json(&root.join("api.json"), &catalog.api)?;
347 fs::write(root.join("fas.json"), cloudcover_aws::fas::FAS_DOCUMENT)?;
348 let mut services = BTreeMap::<&str, Vec<PermissionDetails>>::new();
349 for permission in iam_permission_catalog() {
350 let (service, _) = permission
351 .permission
352 .split_once(':')
353 .ok_or("invalid IAM permission")?;
354 services.entry(service).or_default().push(permission.into());
355 }
356 for (service, permissions) in services {
357 write_json(
358 &root.join(format!("iam/{service}.json")),
359 &IamArtifact {
360 schema_version: SCHEMA_VERSION,
361 permissions,
362 },
363 )?;
364 }
365 write_json(&root.join("terraform/index.json"), &catalog.terraform_index)?;
366 write_json(&root.join("terraform/rows.json"), &catalog.terraform_rows)?;
367 for version in &catalog.terraform_versions {
368 write_json(
369 &root.join(format!("terraform/versions/{}.json", version.version)),
370 version,
371 )?;
372 }
373 Ok(())
374}
375
376fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Box<dyn Error>> {
377 if let Some(parent) = path.parent() {
378 fs::create_dir_all(parent)?;
379 }
380 let contents = serde_json::to_vec(value)?;
381 fs::write(path, contents)?;
382 Ok(())
383}
384
385fn replace_output(
386 output_dir: &Path,
387 parent: &Path,
388 staging: &Path,
389 output_exists: bool,
390) -> Result<(), Box<dyn Error>> {
391 if !output_exists {
392 fs::rename(staging, output_dir)?;
393 return Ok(());
394 }
395
396 let backup = tempfile::tempdir_in(parent)?;
397 let backup_path = backup.path().join("previous");
398 fs::rename(output_dir, &backup_path)?;
399 if let Err(error) = fs::rename(staging, output_dir) {
400 let restore_result = fs::rename(&backup_path, output_dir);
401 return match restore_result {
402 Ok(()) => Err(error.into()),
403 Err(restore_error) => Err(io::Error::other(format!(
404 "failed to replace {}: {error}; failed to restore previous output: {restore_error}",
405 output_dir.display()
406 ))
407 .into()),
408 };
409 }
410 drop(backup);
411 Ok(())
412}