1use anyhow::{anyhow, Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::Path;
14
15#[derive(Debug, Clone, Deserialize)]
22pub struct ManifestFile {
23 pub name: String,
25
26 pub install_target: InstallTarget,
28
29 pub owner: String,
31
32 #[serde(default)]
35 pub install_repos: Vec<String>,
36
37 #[serde(default)]
39 pub sink: SinkConfig,
40
41 pub github: GitHubManifest,
43}
44
45#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum InstallTarget {
48 Org,
49 User,
50}
51
52#[derive(Debug, Clone, Default, Deserialize)]
53#[serde(rename_all = "snake_case", tag = "kind")]
54pub enum SinkConfig {
55 #[default]
57 Stdout,
58 File { path: String },
60 Sops {
64 path: String,
65 secret_name: String,
66 secret_namespace: String,
67 },
68 #[allow(dead_code)]
71 Akeyless { item_path: String },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct GitHubManifest {
82 pub name: String,
84
85 pub url: String,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub description: Option<String>,
91
92 #[serde(default)]
94 pub public: bool,
95
96 #[serde(default)]
99 pub hook_attributes: HookAttributes,
100
101 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
104 pub default_permissions: BTreeMap<String, Permission>,
105
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub default_events: Vec<String>,
109
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub callback_urls: Vec<String>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub setup_url: Option<String>,
117
118 #[serde(default)]
120 pub setup_on_update: bool,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct HookAttributes {
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub url: Option<String>,
127 #[serde(default)]
129 pub active: bool,
130}
131
132#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
133#[serde(rename_all = "lowercase")]
134pub enum Permission {
135 Read,
136 Write,
137 Admin,
138}
139
140pub fn load(path: &Path) -> Result<ManifestFile> {
142 let content = std::fs::read_to_string(path)
143 .with_context(|| format!("failed to read manifest at {}", path.display()))?;
144 let manifest: ManifestFile = serde_yaml_ng::from_str(&content)
145 .with_context(|| format!("failed to parse manifest at {}", path.display()))?;
146 validate(&manifest)?;
147 Ok(manifest)
148}
149
150fn validate(m: &ManifestFile) -> Result<()> {
151 if m.name.is_empty() {
152 return Err(anyhow!("manifest.name is required"));
153 }
154 if m.owner.is_empty() {
155 return Err(anyhow!("manifest.owner is required"));
156 }
157 if m.github.name.is_empty() {
158 return Err(anyhow!("manifest.github.name is required"));
159 }
160 if m.github.url.is_empty() {
161 return Err(anyhow!("manifest.github.url is required"));
162 }
163 Ok(())
164}
165
166impl ManifestFile {
167 pub fn manifest_url(&self) -> String {
172 match self.install_target {
173 InstallTarget::Org => format!(
174 "https://github.com/organizations/{}/settings/apps/new",
175 self.owner
176 ),
177 InstallTarget::User => "https://github.com/settings/apps/new".to_string(),
178 }
179 }
180
181 pub fn manifest_json(&self, redirect_url: &str) -> Result<String> {
183 let mut value = serde_json::to_value(&self.github)?;
185 if let serde_json::Value::Object(ref mut obj) = value {
186 obj.insert(
187 "redirect_url".to_string(),
188 serde_json::Value::String(redirect_url.to_string()),
189 );
190 }
191 Ok(serde_json::to_string(&value)?)
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::io::Write;
199 use tempfile::NamedTempFile;
200
201 fn write_manifest(content: &str) -> NamedTempFile {
202 let mut f = NamedTempFile::new().unwrap();
203 f.write_all(content.as_bytes()).unwrap();
204 f
205 }
206
207 #[test]
208 fn rio_manifest_loads() {
209 let f = write_manifest(
210 r#"
211name: pleme-arc-rio
212install_target: org
213owner: pleme-io
214sink:
215 kind: stdout
216github:
217 name: pleme-arc-rio
218 url: https://github.com/pleme-io
219 default_permissions:
220 contents: read
221 metadata: read
222"#,
223 );
224 let m = load(f.path()).unwrap();
225 assert_eq!(m.name, "pleme-arc-rio");
226 assert!(matches!(m.install_target, InstallTarget::Org));
227 assert_eq!(m.owner, "pleme-io");
228 assert!(matches!(m.sink, SinkConfig::Stdout));
229 assert_eq!(m.github.default_permissions.len(), 2);
230 }
231
232 #[test]
233 fn missing_owner_fails_validation() {
234 let f = write_manifest(
235 r#"
236name: x
237install_target: org
238owner: ""
239sink:
240 kind: stdout
241github:
242 name: x
243 url: https://example.com
244"#,
245 );
246 let err = load(f.path()).unwrap_err();
247 assert!(err.to_string().contains("manifest.owner is required"));
248 }
249
250 #[test]
251 fn manifest_json_embeds_redirect_url() {
252 let f = write_manifest(
253 r#"
254name: x
255install_target: user
256owner: alice
257sink:
258 kind: stdout
259github:
260 name: x
261 url: https://example.com
262"#,
263 );
264 let m = load(f.path()).unwrap();
265 let json = m.manifest_json("http://localhost:1234/callback").unwrap();
266 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
267 assert_eq!(
268 v["redirect_url"].as_str().unwrap(),
269 "http://localhost:1234/callback"
270 );
271 assert_eq!(v["name"].as_str().unwrap(), "x");
272 }
273
274 #[test]
275 fn manifest_json_omits_unset_optional_fields() {
276 let f = write_manifest(
279 r#"
280name: x
281install_target: org
282owner: alice
283sink:
284 kind: stdout
285github:
286 name: x
287 url: https://example.com
288 default_permissions:
289 contents: read
290"#,
291 );
292 let m = load(f.path()).unwrap();
293 let json = m.manifest_json("http://localhost:1234/cb").unwrap();
294 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
295 assert_eq!(v["name"], "x");
297 assert_eq!(v["url"], "https://example.com");
298 assert!(v.get("description").is_none(), "description should be omitted, got {:?}", v.get("description"));
300 assert!(v.get("setup_url").is_none(), "setup_url should be omitted, got {:?}", v.get("setup_url"));
301 let ha = &v["hook_attributes"];
303 assert!(ha.get("url").is_none(), "hook_attributes.url should be omitted, got {:?}", ha.get("url"));
304 assert!(v.get("default_events").is_none() || v["default_events"].as_array().unwrap().is_empty());
306 assert_eq!(v["redirect_url"], "http://localhost:1234/cb");
308 }
309
310 #[test]
311 fn org_manifest_url_is_org_scoped() {
312 let f = write_manifest(
313 r#"
314name: x
315install_target: org
316owner: pleme-io
317sink:
318 kind: stdout
319github:
320 name: x
321 url: https://example.com
322"#,
323 );
324 let m = load(f.path()).unwrap();
325 assert_eq!(
326 m.manifest_url(),
327 "https://github.com/organizations/pleme-io/settings/apps/new"
328 );
329 }
330}