Skip to main content

thirdpass_ansible_lib/
lib.rs

1use anyhow::{format_err, Context, Result};
2use std::io::Read;
3use strum::IntoEnumIterator;
4
5mod galaxy;
6
7#[derive(Clone, Debug)]
8pub struct AnsibleExtension {
9    name_: String,
10    registry_host_names_: Vec<String>,
11    registry_human_url_template_: String,
12}
13
14impl thirdpass_core::extension::FromLib for AnsibleExtension {
15    fn new() -> Self {
16        Self {
17            name_: "ansible".to_string(),
18            registry_host_names_: vec!["galaxy.ansible.com".to_owned()],
19            registry_human_url_template_:
20                "https://galaxy.ansible.com/ui/repo/published/{{package_name}}/".to_string(),
21        }
22    }
23}
24
25impl thirdpass_core::extension::Extension for AnsibleExtension {
26    fn name(&self) -> String {
27        self.name_.clone()
28    }
29
30    fn registries(&self) -> Vec<String> {
31        self.registry_host_names_.clone()
32    }
33
34    /// Returns a list of dependencies for the given package.
35    ///
36    /// Returns one package dependencies structure per registry.
37    fn identify_package_dependencies(
38        &self,
39        _package_name: &str,
40        _package_version: &Option<&str>,
41        _extension_args: &[String],
42    ) -> Result<Vec<thirdpass_core::extension::PackageDependencies>> {
43        Err(format_err!("Function unimplemented."))
44    }
45
46    fn identify_file_defined_dependencies(
47        &self,
48        working_directory: &std::path::Path,
49        _extension_args: &[String],
50    ) -> Result<Vec<thirdpass_core::extension::FileDefinedDependencies>> {
51        // Identify dependency definition file.
52        let dependency_files = identify_dependency_files(working_directory);
53        let dependency_file = match select_preferred_dependency_file(&dependency_files) {
54            Some(dependency_file) => dependency_file,
55            None => return Ok(Vec::new()),
56        };
57
58        let global_dependencies = galaxy::get_global_dependencies()?;
59
60        // Read all dependencies definitions files.
61        let mut dependency_specs = Vec::new();
62        let (dependencies, registry_host_name) = match dependency_file.r#type {
63            DependencyFileType::GalaxyManifest => (
64                galaxy::get_manifest_dependencies(&dependency_file.path, &global_dependencies)?,
65                galaxy::get_registry_host_name(),
66            ),
67            DependencyFileType::GalaxyYml => (
68                galaxy::get_galaxy_yml_dependencies(&dependency_file.path, &global_dependencies)?,
69                galaxy::get_registry_host_name(),
70            ),
71        };
72        dependency_specs.push(thirdpass_core::extension::FileDefinedDependencies {
73            path: dependency_file.path.clone(),
74            registry_host_name,
75            dependencies: dependencies.into_iter().collect(),
76        });
77
78        Ok(dependency_specs)
79    }
80
81    fn registries_package_metadata(
82        &self,
83        package_name: &str,
84        package_version: &Option<&str>,
85    ) -> Result<Vec<thirdpass_core::extension::RegistryPackageMetadata>> {
86        let package_version = match package_version {
87            Some(v) => Some(v.to_string()),
88            None => get_latest_version(package_name)?,
89        }
90        .ok_or(format_err!("Failed to find package version."))?;
91
92        // Query remote package registry for given package.
93        let human_url = get_registry_human_url(self, package_name)?;
94
95        // Currently, only one registry is supported. Therefore simply extract.
96        let registry_host_name = self
97            .registries()
98            .first()
99            .ok_or(format_err!(
100                "Code error: vector of registry host names is empty."
101            ))?
102            .clone();
103
104        let entry_json = get_registry_entry_json(package_name, &package_version)?;
105        let artifact_url = get_archive_url(&entry_json)?;
106
107        Ok(vec![thirdpass_core::extension::RegistryPackageMetadata {
108            registry_host_name,
109            human_url: human_url.to_string(),
110            artifact_url: artifact_url.to_string(),
111            is_primary: true,
112            package_version: package_version.to_string(),
113        }])
114    }
115}
116
117/// Given package name, return latest version.
118fn get_latest_version(package_name: &str) -> Result<Option<String>> {
119    let json = get_registry_versions_json(package_name)?;
120    latest_version_from_versions_json(&json).map(Some)
121}
122
123fn latest_version_from_versions_json(json: &serde_json::Value) -> Result<String> {
124    let version_entries = json["data"]
125        .as_array()
126        .ok_or(format_err!("Failed to find data JSON section."))?;
127
128    let mut versions = Vec::<semver::Version>::new();
129    for version_entry in version_entries {
130        let version_entry = version_entry
131            .as_object()
132            .ok_or(format_err!("Failed to parse version entry as JSON object."))?;
133        let version = version_entry["version"]
134            .as_str()
135            .ok_or(format_err!("Failed to parse version as str."))?;
136        let version = match semver::Version::parse(version) {
137            Ok(v) => v,
138            Err(_) => continue,
139        };
140        versions.push(version);
141    }
142    versions.sort();
143
144    let latest_version = versions
145        .last()
146        .ok_or(format_err!("Failed to find latest version."))?;
147    Ok(latest_version.to_string())
148}
149
150fn get_registry_human_url(extension: &AnsibleExtension, package_name: &str) -> Result<url::Url> {
151    // Example return value: https://galaxy.ansible.com/crivetimihai/development
152    let package_name = package_name.replace(".", "/");
153    let handlebars_registry = handlebars::Handlebars::new();
154    let url = handlebars_registry.render_template(
155        &extension.registry_human_url_template_,
156        &maplit::btreemap! {
157            "package_name" => package_name,
158        },
159    )?;
160    Ok(url::Url::parse(url.as_str())?)
161}
162
163fn get_registry_versions_json(package_name: &str) -> Result<serde_json::Value> {
164    let package_name = package_name.replace(".", "/");
165    let handlebars_registry = handlebars::Handlebars::new();
166    let json_url = handlebars_registry.render_template(
167        "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/{{package_name}}/versions/",
168        &maplit::btreemap! {"package_name" => package_name},
169    )?;
170
171    get_registry_json(&json_url)
172}
173
174fn get_registry_entry_json(package_name: &str, package_version: &str) -> Result<serde_json::Value> {
175    let package_name = package_name.replace(".", "/");
176    let handlebars_registry = handlebars::Handlebars::new();
177    let json_url = handlebars_registry.render_template(
178        "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/{{package_name}}/versions/{{package_version}}/",
179        &maplit::btreemap! {"package_name" => package_name, "package_version" => package_version.to_string()},
180    )?;
181
182    get_registry_json(&json_url)
183}
184
185fn get_registry_json(json_url: &str) -> Result<serde_json::Value> {
186    let mut result = reqwest::blocking::get(json_url)?;
187    let status = result.status();
188    let mut body = String::new();
189    result.read_to_string(&mut body)?;
190    if !status.is_success() {
191        return Err(format_err!(
192            "Galaxy registry request failed ({}): {}",
193            status,
194            body
195        ));
196    }
197
198    serde_json::from_str(&body).context(format!("JSON was not well-formatted:\n{}", body))
199}
200
201fn get_archive_url(registry_entry_json: &serde_json::Value) -> Result<url::Url> {
202    Ok(url::Url::parse(
203        registry_entry_json["download_url"]
204            .as_str()
205            .ok_or(format_err!("Failed to parse package archive URL."))?,
206    )?)
207}
208
209/// Package dependency file types.
210#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
211enum DependencyFileType {
212    GalaxyManifest,
213    GalaxyYml,
214}
215
216impl DependencyFileType {
217    /// Return file name associated with dependency type.
218    pub fn file_name(&self) -> std::path::PathBuf {
219        match self {
220            Self::GalaxyManifest => std::path::PathBuf::from("MANIFEST.json"),
221            Self::GalaxyYml => std::path::PathBuf::from("galaxy.yml"),
222        }
223    }
224}
225
226/// Package dependency file type and file path.
227#[derive(Debug, Clone)]
228struct DependencyFile {
229    r#type: DependencyFileType,
230    path: std::path::PathBuf,
231}
232
233/// Select preferred galaxy.yml dependency file type.
234fn select_preferred_dependency_file(
235    dependency_files: &[DependencyFile],
236) -> Option<&DependencyFile> {
237    if dependency_files
238        .iter()
239        .any(|file| matches!(file.r#type, DependencyFileType::GalaxyYml))
240    {
241        dependency_files
242            .iter()
243            .find(|file| matches!(file.r#type, DependencyFileType::GalaxyYml))
244    } else {
245        dependency_files.first()
246    }
247}
248
249/// Returns a vector of identified package dependency definition files.
250///
251/// Walks up the directory tree directory tree until the first positive result is found.
252fn identify_dependency_files(working_directory: &std::path::Path) -> Vec<DependencyFile> {
253    assert!(working_directory.is_absolute());
254    let mut working_directory = working_directory.to_path_buf();
255
256    loop {
257        // If at least one target is found, assume package is present.
258        let mut found_dependency_file = false;
259
260        let mut dependency_files: Vec<DependencyFile> = Vec::new();
261        for dependency_file_type in DependencyFileType::iter() {
262            let target_absolute_path = working_directory.join(dependency_file_type.file_name());
263            if target_absolute_path.is_file() {
264                found_dependency_file = true;
265                dependency_files.push(DependencyFile {
266                    r#type: dependency_file_type,
267                    path: target_absolute_path,
268                })
269            }
270        }
271        if found_dependency_file {
272            return dependency_files;
273        }
274
275        // No need to move further up the directory tree after this loop.
276        if working_directory == std::path::Path::new("/") {
277            break;
278        }
279
280        // Move further up the directory tree.
281        working_directory.pop();
282    }
283    Vec::new()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use thirdpass_core::extension::FromLib;
290
291    #[test]
292    fn latest_version_reads_galaxy_v3_data_entries() -> Result<()> {
293        let json = serde_json::json!({
294            "meta": { "count": 2 },
295            "data": [
296                { "version": "1.0.0" },
297                { "version": "1.2.0" }
298            ]
299        });
300
301        assert_eq!(latest_version_from_versions_json(&json)?, "1.2.0");
302        Ok(())
303    }
304
305    #[test]
306    fn archive_url_reads_galaxy_v3_download_url() -> Result<()> {
307        let json = serde_json::json!({
308            "download_url": "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/c01110011-protonpass-1.0.0.tar.gz"
309        });
310
311        assert_eq!(
312            get_archive_url(&json)?.as_str(),
313            "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/c01110011-protonpass-1.0.0.tar.gz"
314        );
315        Ok(())
316    }
317
318    #[test]
319    fn human_url_uses_published_collection_route() -> Result<()> {
320        let extension = AnsibleExtension::new();
321
322        assert_eq!(
323            get_registry_human_url(&extension, "c01110011.protonpass")?.as_str(),
324            "https://galaxy.ansible.com/ui/repo/published/c01110011/protonpass/"
325        );
326        Ok(())
327    }
328}