thirdpass_ansible_lib/
lib.rs1use 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 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 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 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 let human_url = get_registry_human_url(self, package_name)?;
94
95 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
117fn 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 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#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
211enum DependencyFileType {
212 GalaxyManifest,
213 GalaxyYml,
214}
215
216impl DependencyFileType {
217 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#[derive(Debug, Clone)]
228struct DependencyFile {
229 r#type: DependencyFileType,
230 path: std::path::PathBuf,
231}
232
233fn 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
249fn 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 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 if working_directory == std::path::Path::new("/") {
277 break;
278 }
279
280 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}