use std::path::Path;
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use crate::cas::Digest;
pub const GENERATOR_PROFILE_VERSION: &str = "knot-static/gen-19";
/// bump this when the conf shape changes, or the id won't rotate.
pub const NGINX_TEMPLATE_VERSION: &str = "knot-static/nginx-33";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepoEntry {
pub repo_did: String,
pub owner: String,
pub rkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
pub public_refs: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cob_refs: Vec<(String, String)>,
pub object_format: String,
pub fingerprint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_export_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fetch_bases: Vec<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shallow_branches: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shallow_head: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub moved_wants: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct PhaseTimings {
#[serde(skip_serializing_if = "Option::is_none")]
pub planning_seconds: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_export_seconds: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct BuildStats {
pub repos: usize,
pub public_refs: usize,
pub git_export_bytes: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_seconds: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timings: Option<PhaseTimings>,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub created_at_unix: u64,
}
fn is_zero_u64(value: &u64) -> bool {
*value == 0
}
fn is_zero_stats(stats: &BuildStats) -> bool {
*stats == BuildStats::default()
}
pub fn short_id(generation_id: &str) -> &str {
generation_id.get(..7).unwrap_or(generation_id)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerationManifest {
pub generation_id: String,
pub generator_profile: String,
#[serde(default)]
pub conf_template: String,
pub knot_did: String,
pub hostname: String,
pub owner: String,
pub static_version: String,
/// the binary that built it, a generation built by another one isn't unchanged
#[serde(default, skip_serializing_if = "String::is_empty")]
pub built_by: String,
pub profile: serde_json::Value,
pub repos: Vec<RepoEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub global_input_digest: Option<String>,
/// the handle cache's answers, see `index_page::served_digest`
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handles_digest: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rebuilt_repos: Vec<String>,
#[serde(default, skip_serializing_if = "is_zero_stats")]
pub stats: BuildStats,
}
impl GenerationManifest {
pub fn compute_id(&self) -> String {
let mut semantic = self.clone();
semantic.generation_id = String::new();
semantic.stats = BuildStats::default();
semantic.rebuilt_repos.clear();
let bytes = serde_json::to_vec(&semantic).expect("manifest serializes");
Digest::of_bytes(&bytes).hex()
}
pub fn finalize_and_write(&mut self, generation_dir: &Path) -> anyhow::Result<()> {
self.generation_id = self.compute_id();
let bytes = serde_json::to_vec_pretty(self)?;
crate::cas::atomic_write(&generation_dir.join("manifest.json"), &bytes)?;
Ok(())
}
pub fn load(generation_dir: &Path) -> anyhow::Result<Self> {
let bytes = std::fs::read(generation_dir.join("manifest.json"))
.with_context(|| format!("read manifest in {}", generation_dir.display()))?;
serde_json::from_slice(&bytes).context("parse generation manifest")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest() -> GenerationManifest {
GenerationManifest {
generation_id: String::new(),
generator_profile: GENERATOR_PROFILE_VERSION.to_string(),
conf_template: NGINX_TEMPLATE_VERSION.to_string(),
knot_did: "did:web:knot.test".to_string(),
hostname: "knot.test".to_string(),
owner: "did:plc:owner".to_string(),
static_version: "test".to_string(),
built_by: String::new(),
profile: serde_json::json!({ "atproto": {} }),
repos: Vec::new(),
global_input_digest: None,
handles_digest: None,
rebuilt_repos: Vec::new(),
stats: BuildStats::default(),
}
}
#[test]
fn id_ignores_bookkeeping_and_follows_content() {
let base = manifest();
let mut noisy = base.clone();
noisy.rebuilt_repos = vec!["did:plc:a".to_string()];
noisy.stats.build_seconds = Some(1.5);
assert_eq!(base.compute_id(), noisy.compute_id());
let mut moved = base.clone();
moved.profile = serde_json::json!({ "atproto": { "did:plc:a": "bafy" } });
assert_ne!(base.compute_id(), moved.compute_id());
}
#[test]
fn written_manifest_loads_back_with_its_id() {
let dir = tempfile::tempdir().unwrap();
let mut written = manifest();
written.finalize_and_write(dir.path()).unwrap();
let loaded = GenerationManifest::load(dir.path()).unwrap();
assert_eq!(loaded.generation_id, written.compute_id());
}
}