use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
pub struct ManifestFile {
pub name: String,
pub install_target: InstallTarget,
pub owner: String,
#[serde(default)]
pub install_repos: Vec<String>,
#[serde(default)]
pub sink: SinkConfig,
pub github: GitHubManifest,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InstallTarget {
Org,
User,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum SinkConfig {
#[default]
Stdout,
File { path: String },
Sops {
path: String,
secret_name: String,
secret_namespace: String,
},
#[allow(dead_code)]
Akeyless { item_path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubManifest {
pub name: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub public: bool,
#[serde(default)]
pub hook_attributes: HookAttributes,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub default_permissions: BTreeMap<String, Permission>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub default_events: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub callback_urls: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup_url: Option<String>,
#[serde(default)]
pub setup_on_update: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HookAttributes {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default)]
pub active: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Permission {
Read,
Write,
Admin,
}
pub fn load(path: &Path) -> Result<ManifestFile> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read manifest at {}", path.display()))?;
let manifest: ManifestFile = serde_yaml_ng::from_str(&content)
.with_context(|| format!("failed to parse manifest at {}", path.display()))?;
validate(&manifest)?;
Ok(manifest)
}
fn validate(m: &ManifestFile) -> Result<()> {
if m.name.is_empty() {
return Err(anyhow!("manifest.name is required"));
}
if m.owner.is_empty() {
return Err(anyhow!("manifest.owner is required"));
}
if m.github.name.is_empty() {
return Err(anyhow!("manifest.github.name is required"));
}
if m.github.url.is_empty() {
return Err(anyhow!("manifest.github.url is required"));
}
Ok(())
}
impl ManifestFile {
pub fn manifest_url(&self) -> String {
match self.install_target {
InstallTarget::Org => format!(
"https://github.com/organizations/{}/settings/apps/new",
self.owner
),
InstallTarget::User => "https://github.com/settings/apps/new".to_string(),
}
}
pub fn manifest_json(&self, redirect_url: &str) -> Result<String> {
let mut value = serde_json::to_value(&self.github)?;
if let serde_json::Value::Object(ref mut obj) = value {
obj.insert(
"redirect_url".to_string(),
serde_json::Value::String(redirect_url.to_string()),
);
}
Ok(serde_json::to_string(&value)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_manifest(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn rio_manifest_loads() {
let f = write_manifest(
r#"
name: pleme-arc-rio
install_target: org
owner: pleme-io
sink:
kind: stdout
github:
name: pleme-arc-rio
url: https://github.com/pleme-io
default_permissions:
contents: read
metadata: read
"#,
);
let m = load(f.path()).unwrap();
assert_eq!(m.name, "pleme-arc-rio");
assert!(matches!(m.install_target, InstallTarget::Org));
assert_eq!(m.owner, "pleme-io");
assert!(matches!(m.sink, SinkConfig::Stdout));
assert_eq!(m.github.default_permissions.len(), 2);
}
#[test]
fn missing_owner_fails_validation() {
let f = write_manifest(
r#"
name: x
install_target: org
owner: ""
sink:
kind: stdout
github:
name: x
url: https://example.com
"#,
);
let err = load(f.path()).unwrap_err();
assert!(err.to_string().contains("manifest.owner is required"));
}
#[test]
fn manifest_json_embeds_redirect_url() {
let f = write_manifest(
r#"
name: x
install_target: user
owner: alice
sink:
kind: stdout
github:
name: x
url: https://example.com
"#,
);
let m = load(f.path()).unwrap();
let json = m.manifest_json("http://localhost:1234/callback").unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
v["redirect_url"].as_str().unwrap(),
"http://localhost:1234/callback"
);
assert_eq!(v["name"].as_str().unwrap(), "x");
}
#[test]
fn manifest_json_omits_unset_optional_fields() {
let f = write_manifest(
r#"
name: x
install_target: org
owner: alice
sink:
kind: stdout
github:
name: x
url: https://example.com
default_permissions:
contents: read
"#,
);
let m = load(f.path()).unwrap();
let json = m.manifest_json("http://localhost:1234/cb").unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["name"], "x");
assert_eq!(v["url"], "https://example.com");
assert!(v.get("description").is_none(), "description should be omitted, got {:?}", v.get("description"));
assert!(v.get("setup_url").is_none(), "setup_url should be omitted, got {:?}", v.get("setup_url"));
let ha = &v["hook_attributes"];
assert!(ha.get("url").is_none(), "hook_attributes.url should be omitted, got {:?}", ha.get("url"));
assert!(v.get("default_events").is_none() || v["default_events"].as_array().unwrap().is_empty());
assert_eq!(v["redirect_url"], "http://localhost:1234/cb");
}
#[test]
fn org_manifest_url_is_org_scoped() {
let f = write_manifest(
r#"
name: x
install_target: org
owner: pleme-io
sink:
kind: stdout
github:
name: x
url: https://example.com
"#,
);
let m = load(f.path()).unwrap();
assert_eq!(
m.manifest_url(),
"https://github.com/organizations/pleme-io/settings/apps/new"
);
}
}