crates/kernel/ents-effect/src/results.rs
results.rshistorycomment on this file
| 1 | //! Writing a run's outcome back to the repository (`effect.results-writeback`, |
| 2 | //! `effect.identity`): an ordinary [`ents_receive::receive`] client, never a |
| 3 | //! privileged write outside the gate. |
| 4 | //! |
| 5 | //! [`write_result`] builds the [`ents_model::ResultRecord`] typed tree, |
| 6 | //! seals it into a signed commit exactly the way |
| 7 | //! [`ents_sync::resolve::merge_heads`] seals a merge tip (`sign` is a |
| 8 | //! caller-injected closure, so this crate never holds key material — the |
| 9 | //! composition root injects the worker's own member key, `effect.identity`: |
| 10 | //! "its result commit MUST be signed with its own member key"), and hands |
| 11 | //! the result to `receive` like any other frontend. |
| 12 | |
| 13 | use ents_model::{ResultRecord, Status}; |
| 14 | use ents_receive::{EventSink, Mode, Outcome, Proposal, RefTransition}; |
| 15 | use gix::refs::FullName; |
| 16 | use gix_hash::ObjectId; |
| 17 | use gix_object::{Commit, Find, Kind, Write, WriteTo as _}; |
| 18 | use gix_ref_store::RefStore; |
| 19 | |
| 20 | use crate::error::{Error, Result}; |
| 21 | |
| 22 | /// Build a signed commit recording `status` for `effect` on the commit |
| 23 | /// `target` at `results_ref`, and push it through |
| 24 | /// [`ents_receive::receive`] — the sole path an effect's outcome may |
| 25 | /// re-enter the repository (`effect.results-writeback`). |
| 26 | /// |
| 27 | /// The tree is a [`ResultRecord`] carrying `effect` and `target` |
| 28 | /// (`model.result-identity`), from which the gate recomputes |
| 29 | /// `results_ref`'s `<effect>` and `<short-oid>` segments |
| 30 | /// (`gate.identity-binding`): a signed `pass` cannot be replayed as the |
| 31 | /// result of a different effect or commit. |
| 32 | /// |
| 33 | /// `results_ref` is the caller's choice: the canonical |
| 34 | /// `refs/meta/results/<effect>/<short-oid>` for a designated worker, or the |
| 35 | /// self-run `refs/meta/self/<member>/<effect>/<short-oid>` for any other |
| 36 | /// member running the same effect on their own account |
| 37 | /// (`effect.self-run`). Which one is "official" is a refname authorization |
| 38 | /// rule the gate enforces (`effect.official`), not a decision this |
| 39 | /// function makes. |
| 40 | /// |
| 41 | /// # Errors |
| 42 | /// |
| 43 | /// [`Error::Facet`] if `status` cannot be serialized; [`Error::Refs`] if |
| 44 | /// reading `results_ref`'s current tip fails; [`Error::Receive`] if |
| 45 | /// `receive` itself could not reach an outcome. |
| 46 | /// |
| 47 | /// # Examples |
| 48 | /// |
| 49 | /// ``` |
| 50 | /// use ents_effect::write_result; |
| 51 | /// use ents_model::Status; |
| 52 | /// use ents_receive::{Mode, NullEventSink}; |
| 53 | /// use ents_testutil::{Keypair, MemRefStore, ObjectStore}; |
| 54 | /// |
| 55 | /// let refs = MemRefStore::default(); |
| 56 | /// let objects = ObjectStore::default(); |
| 57 | /// let key = Keypair::from_seed(1); |
| 58 | /// let author = gix::actor::Signature { |
| 59 | /// name: "worker".into(), |
| 60 | /// email: "worker@ents.test".into(), |
| 61 | /// time: gix::date::Time { seconds: 1_000, offset: 0 }, |
| 62 | /// }; |
| 63 | /// |
| 64 | /// let target = gix_hash::ObjectId::from_hex(b"abc123456789000000000000000000000000abcd").expect("hex"); |
| 65 | /// let name: gix::refs::FullName = |
| 66 | /// "refs/meta/results/unit/abc123456789".try_into().expect("valid"); |
| 67 | /// let outcome = write_result( |
| 68 | /// &refs, &objects, &NullEventSink, name, "unit", target, Status::Pass, &author, |
| 69 | /// |payload| key.sign(payload), Mode::Advisory, |
| 70 | /// ).expect("evaluates"); |
| 71 | /// assert_eq!(outcome.result, ents_receive::TxResult::Applied); |
| 72 | /// ``` |
| 73 | // @relation(effect.results-writeback, effect.identity, effect.result-taxonomy, effect.self-run, model.result-identity, scope=function) |
| 74 | #[expect( |
| 75 | clippy::too_many_arguments, |
| 76 | reason = "one input per commit-building step, mirrors ents_sync::resolve::merge_heads's shape" |
| 77 | )] |
| 78 | pub fn write_result( |
| 79 | refs: &dyn RefStore, |
| 80 | objects: &(impl Find + Write), |
| 81 | events: &dyn EventSink, |
| 82 | results_ref: FullName, |
| 83 | effect: &str, |
| 84 | target: ObjectId, |
| 85 | status: Status, |
| 86 | author: &gix::actor::Signature, |
| 87 | sign: impl FnOnce(&[u8]) -> String, |
| 88 | mode: Mode, |
| 89 | ) -> Result<Outcome> { |
| 90 | let record = ResultRecord::new(effect, target, status); |
| 91 | let tree = facet_git_tree::serialize_into(&record, objects)?; |
| 92 | let old = refs.get(results_ref.as_ref())?; |
| 93 | let parents: Vec<_> = old.into_iter().collect(); |
| 94 | |
| 95 | let summary = match status { |
| 96 | Status::Pass => "Record pass", |
| 97 | Status::Fail => "Record fail", |
| 98 | Status::Error => "Record error", |
| 99 | }; |
| 100 | let message = summary.to_owned(); |
| 101 | let mut commit = Commit { |
| 102 | tree, |
| 103 | parents: parents.clone().into(), |
| 104 | author: author.clone(), |
| 105 | committer: author.clone(), |
| 106 | encoding: None, |
| 107 | message: message.into(), |
| 108 | extra_headers: Vec::new(), |
| 109 | }; |
| 110 | |
| 111 | let mut payload = Vec::new(); |
| 112 | commit.write_to(&mut payload).map_err(|e| Error::Decode { |
| 113 | oid: tree, |
| 114 | detail: format!("serializing result commit failed: {e}"), |
| 115 | })?; |
| 116 | let pem = sign(&payload); |
| 117 | commit |
| 118 | .extra_headers |
| 119 | .push(("gpgsig".into(), pem.trim_end().into())); |
| 120 | |
| 121 | let mut raw = Vec::new(); |
| 122 | commit.write_to(&mut raw).map_err(|e| Error::Decode { |
| 123 | oid: tree, |
| 124 | detail: format!("serializing signed result commit failed: {e}"), |
| 125 | })?; |
| 126 | let tip = objects |
| 127 | .write_buf(Kind::Commit, &raw) |
| 128 | .map_err(|e| Error::Decode { |
| 129 | oid: tree, |
| 130 | detail: e.to_string(), |
| 131 | })?; |
| 132 | |
| 133 | let proposal = Proposal { |
| 134 | transitions: vec![RefTransition { |
| 135 | name: results_ref, |
| 136 | old, |
| 137 | new: Some(tip), |
| 138 | }], |
| 139 | objects: vec![tip], |
| 140 | auth: None, |
| 141 | }; |
| 142 | Ok(ents_receive::receive( |
| 143 | refs, objects, events, &proposal, mode, |
| 144 | )?) |
| 145 | } |
| 146 | |
| 147 | #[cfg(test)] |
| 148 | mod tests { |
| 149 | #![allow(clippy::expect_used, reason = "unit test")] |
| 150 | |
| 151 | use ents_model::{Provenance, namespace}; |
| 152 | use ents_receive::{NullEventSink, TxResult}; |
| 153 | use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member}; |
| 154 | use gix_ref_store::RefStoreRead as _; |
| 155 | use rstest::rstest; |
| 156 | |
| 157 | use super::*; |
| 158 | |
| 159 | fn author() -> gix::actor::Signature { |
| 160 | gix::actor::Signature { |
| 161 | name: "worker".into(), |
| 162 | email: "worker@ents.test".into(), |
| 163 | time: gix::date::Time { |
| 164 | seconds: 1_000, |
| 165 | offset: 0, |
| 166 | }, |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | #[rstest] |
| 171 | #[case::pass(Status::Pass)] |
| 172 | #[case::fail(Status::Fail)] |
| 173 | #[case::error(Status::Error)] |
| 174 | // @relation(effect.results-writeback, effect.identity, effect.result-taxonomy, scope=function, role=Verifies) |
| 175 | fn write_result_lands_a_signed_commit_on_the_canonical_results_ref(#[case] status: Status) { |
| 176 | let refs = MemRefStore::default(); |
| 177 | let objects = ObjectStore::default(); |
| 178 | let worker = Keypair::from_seed(1); |
| 179 | enroll_member( |
| 180 | &refs, |
| 181 | &objects, |
| 182 | "worker", |
| 183 | &worker, |
| 184 | Provenance::AdminRegistered, |
| 185 | 100, |
| 186 | ); |
| 187 | |
| 188 | let name = namespace::result_ref("unit", "deadbeefcafe").expect("valid"); |
| 189 | let target = |
| 190 | ObjectId::from_hex(b"deadbeefcafe0000000000000000000000000000").expect("valid hex"); |
| 191 | let outcome = write_result( |
| 192 | &refs, |
| 193 | &objects, |
| 194 | &NullEventSink, |
| 195 | name.clone(), |
| 196 | "unit", |
| 197 | target, |
| 198 | status, |
| 199 | &author(), |
| 200 | |payload| worker.sign(payload), |
| 201 | Mode::Advisory, |
| 202 | ) |
| 203 | .expect("evaluates"); |
| 204 | assert_eq!(outcome.result, TxResult::Applied); |
| 205 | // The gate validated the commit's signature against the worker's |
| 206 | // own member key — `effect.identity`: "its result commit MUST be |
| 207 | // signed with its own member key". |
| 208 | let (_, verdict) = outcome.verdicts.first().expect("one transition proposed"); |
| 209 | assert!(verdict.is_pass()); |
| 210 | |
| 211 | let tip = refs.get(name.as_ref()).expect("readable").expect("landed"); |
| 212 | let mut buf = Vec::new(); |
| 213 | let data = gix_object::Find::try_find(&objects, &tip, &mut buf) |
| 214 | .expect("readable") |
| 215 | .expect("present"); |
| 216 | let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("decodes"); |
| 217 | let landed: ResultRecord = |
| 218 | facet_git_tree::deserialize(&commit.tree(), &objects).expect("deserializes"); |
| 219 | assert_eq!(landed.status, status); |
| 220 | assert_eq!(landed.effect, "unit"); |
| 221 | assert_eq!(landed.target(), target); |
| 222 | } |
| 223 | |
| 224 | #[rstest] |
| 225 | // @relation(effect.self-run, effect.results-writeback, scope=function, role=Verifies) |
| 226 | fn write_result_can_target_the_self_run_namespace() { |
| 227 | let refs = MemRefStore::default(); |
| 228 | let objects = ObjectStore::default(); |
| 229 | let member = Keypair::from_seed(2); |
| 230 | enroll_member( |
| 231 | &refs, |
| 232 | &objects, |
| 233 | "bob", |
| 234 | &member, |
| 235 | Provenance::AdminRegistered, |
| 236 | 100, |
| 237 | ); |
| 238 | |
| 239 | let name = namespace::self_result_ref(&ents_model::MemberId::new("bob"), "unit", "abc") |
| 240 | .expect("valid"); |
| 241 | let target = |
| 242 | ObjectId::from_hex(b"abc0000000000000000000000000000000000000").expect("valid hex"); |
| 243 | let outcome = write_result( |
| 244 | &refs, |
| 245 | &objects, |
| 246 | &NullEventSink, |
| 247 | name.clone(), |
| 248 | "unit", |
| 249 | target, |
| 250 | Status::Fail, |
| 251 | &author(), |
| 252 | |payload| member.sign(payload), |
| 253 | Mode::Advisory, |
| 254 | ) |
| 255 | .expect("evaluates"); |
| 256 | assert_eq!(outcome.result, TxResult::Applied); |
| 257 | assert!(refs.get(name.as_ref()).expect("readable").is_some()); |
| 258 | } |
| 259 | } |