git-ents.gitmain
⌘K
foforge
results.rs259 lines · 9.1 KB · rusthistorycomment 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
13use ents_model::{ResultRecord, Status};
14use ents_receive::{EventSink, Mode, Outcome, Proposal, RefTransition};
15use gix::refs::FullName;
16use gix_hash::ObjectId;
17use gix_object::{Commit, Find, Kind, Write, WriteTo as _};
18use gix_ref_store::RefStore;
19
20use 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)]
78pub 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)]
148mod 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}