Skip to main content

cloudcover_site_data/
lib.rs

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
118/// Generates the complete static data tree used by the browser application.
119///
120/// # Errors
121///
122/// Returns an error when source data cannot be resolved, an artifact cannot be
123/// serialized or written, or the output directory cannot be replaced.
124pub 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}