Skip to main content

gridwork/
lib.rs

1//! `gw` — one binary, and for now one of its three modes.
2//!
3//! What is here is the CLI: it opens the host-local socket, asks one question,
4//! and prints the answer as the protocol produced it. Nothing is reshaped on the
5//! way through. A `health` answer is the contract's `health` value and a refusal
6//! is the contract's error object, so a caller that already knows the wire needs
7//! no second vocabulary for the command line — and this program has no view of
8//! its own to drift out of step.
9//!
10//! Three rules hold everywhere:
11//!
12//! * **The answer is JSON on standard output.** `--pretty` changes the
13//!   formatting and never the value. `--help` is the single exception, because a
14//!   caller asking for the command tree is asking for prose.
15//! * **The exit says what to do about it** ([`exit`]). One table, derived from
16//!   the refusal's own code, so the exit and the JSON can never disagree.
17//! * **No database, no key material.** Every verb here goes through the socket.
18//!   The database and the KEK belong to `daemon` and `admin`, which is the whole
19//!   reason those two are separate verbs.
20
21pub mod admin;
22pub mod args;
23pub mod client;
24pub mod exit;
25
26use std::path::{Path, PathBuf};
27
28use args::{Invocation, Sink, Source, Verb};
29use base64::prelude::{BASE64_STANDARD, Engine as _};
30use client::Client;
31use exit::Failure;
32use gwk_domain::blob::{BLOB_CHUNK_BYTES, BlobAddress};
33use gwk_domain::command::KernelCommand;
34use gwk_domain::envelope::{Actor, CommandEnvelope, ENVELOPE_SCHEMA_VERSION, Origin};
35use gwk_domain::ids::{
36    AttentionItemId, AuthorityGrantId, ByteCount, CommandId, IdempotencyKey, ProjectId, Seq,
37    Timestamp,
38};
39use gwk_domain::protocol::{
40    CONTRACT_VERSION, KernelErrorCode, KernelRequest, KernelResult, ServerControl,
41};
42use serde_json::Value;
43use tokio::io::AsyncWriteExt;
44
45/// The revision this build came from, or nothing if it was not stamped.
46///
47/// `None` is a real answer and not a defect — see `build.rs`. A daemon cannot
48/// serve without one, but every verb in this module can, so the CLI reports the
49/// absence rather than inventing a value.
50pub const PUBLIC_REVISION: Option<&str> = option_env!("GW_PUBLIC_REVISION");
51
52/// Run one invocation and return the exit it earned.
53pub async fn run(argv: &[String]) -> u8 {
54    let invocation = match args::parse(argv) {
55        Ok(invocation) => invocation,
56        Err(failure) => return report(&failure, false),
57    };
58    let Invocation { verb, pretty } = invocation;
59    match execute(verb, pretty).await {
60        Ok(()) => exit::OK,
61        Err(failure) => report(&failure, pretty),
62    }
63}
64
65/// Print a failure in the protocol's own error shape, and say what it exits as.
66fn report(failure: &Failure, pretty: bool) -> u8 {
67    emit(&failure.to_json(), pretty);
68    failure.exit
69}
70
71fn emit(value: &Value, pretty: bool) {
72    let rendered = if pretty {
73        serde_json::to_string_pretty(value)
74    } else {
75        serde_json::to_string(value)
76    };
77    match rendered {
78        Ok(text) => println!("{text}"),
79        // Unreachable for a `Value`, and not worth a panic if it ever is.
80        Err(e) => eprintln!("gw: could not render an answer: {e}"),
81    }
82}
83
84async fn execute(verb: Verb, pretty: bool) -> Result<(), Failure> {
85    match verb {
86        Verb::Help => {
87            print!("{}", args::HELP);
88            Ok(())
89        }
90        Verb::BuildInfo => {
91            emit(&build_info(), pretty);
92            Ok(())
93        }
94
95        // The two verbs that hold a database and a key. Kept in one module so
96        // which paths touch credentials is a question answered by reading the
97        // imports.
98        Verb::Daemon => admin::daemon(pretty).await,
99        Verb::AdminInit => admin::init(pretty).await,
100        Verb::AdminVerify => admin::verify(pretty).await,
101        Verb::AdminRebuildProjections { scratch } => {
102            admin::rebuild_projections(&scratch, pretty).await
103        }
104        Verb::AdminBlob { what } => admin::retention(&what, pretty).await,
105
106        // Everything below needs the daemon.
107        Verb::Health => ask(KernelRequest::Health {}, pretty).await,
108        Verb::Status => ask(KernelRequest::Status {}, pretty).await,
109        Verb::Watermark => ask(KernelRequest::Watermark {}, pretty).await,
110        Verb::VerifySealed => ask(KernelRequest::VerifySealed {}, pretty).await,
111
112        Verb::Activate {
113            cutover_id,
114            manifest_sha256,
115        } => {
116            let command = KernelCommand::ActivateKernel {
117                cutover_id: cutover_id.clone(),
118                archive_manifest_sha256: manifest_sha256,
119            };
120            // The key is the cutover's, so a retried activation is the SAME
121            // activation. A second cutover id is refused by the kernel, which is
122            // the check that matters and is not this program's to make.
123            submit(&command, &format!("kernel_activated:{cutover_id}"), pretty).await
124        }
125
126        Verb::CommandSubmit { source } => {
127            let envelope: CommandEnvelope = document(&source)?;
128            ask(KernelRequest::SubmitCommand { envelope }, pretty).await
129        }
130
131        Verb::ProjectionGet { kind, id } => {
132            ask(
133                KernelRequest::GetProjection {
134                    projection: kind,
135                    id,
136                },
137                pretty,
138            )
139            .await
140        }
141        Verb::ProjectionList {
142            kind,
143            cursor,
144            limit,
145        } => {
146            ask(
147                KernelRequest::ListProjection {
148                    projection: kind,
149                    cursor,
150                    limit,
151                },
152                pretty,
153            )
154            .await
155        }
156
157        Verb::EventRead { cursor, limit } => {
158            ask(KernelRequest::ReadEvents { cursor, limit }, pretty).await
159        }
160        Verb::EventFollow { cursor } => follow(cursor, pretty).await,
161
162        Verb::AttentionResolve { id, resolution } => {
163            let command = KernelCommand::ResolveAttention {
164                attention_item_id: AttentionItemId::new(id.clone()),
165                resolution,
166            };
167            submit(&command, &format!("resolve_attention:{id}"), pretty).await
168        }
169        Verb::AuthorityGrant { source } => {
170            let envelope: CommandEnvelope = document(&source)?;
171            // Checked here rather than left to the kernel, because the kernel
172            // would accept it: `gw authority grant` pointed at the wrong file
173            // would submit whatever the file held under a verb that promised
174            // otherwise.
175            expect_command(&envelope, "grant_authority")?;
176            ask(KernelRequest::SubmitCommand { envelope }, pretty).await
177        }
178        Verb::AuthorityRevoke { id, reason } => {
179            let command = KernelCommand::RevokeAuthority {
180                authority_grant_id: AuthorityGrantId::new(id.clone()),
181                reason,
182            };
183            submit(&command, &format!("revoke_authority:{id}"), pretty).await
184        }
185
186        Verb::BlobPut { source, media_type } => blob_put(&source, media_type, pretty).await,
187        Verb::BlobGet { address, output } => blob_get(&address, &output, pretty).await,
188        Verb::BlobStat { address } => ask(KernelRequest::BlobStat { address }, pretty).await,
189
190        Verb::IngestSubmit {
191            kind,
192            source,
193            project,
194            key,
195        } => {
196            let payload: Value = document(&source)?;
197            let key =
198                key.unwrap_or_else(|| format!("{}:{}", kind.as_str(), digest_of_json(&payload)));
199            let command = KernelCommand::IngestRecord {
200                kind,
201                payload,
202                payload_ref: None,
203            };
204            let envelope = envelope(
205                &command,
206                &key,
207                project.as_deref().unwrap_or(gwk_kernel::SYSTEM_PROJECT),
208            );
209            ask(KernelRequest::SubmitCommand { envelope }, pretty).await
210        }
211    }
212}
213
214/// What this build is, without asking anything.
215fn build_info() -> Value {
216    serde_json::json!({
217        "type": "build_info",
218        "crate_version": env!("CARGO_PKG_VERSION"),
219        "contract_version": CONTRACT_VERSION,
220        // Null when the build was not stamped from a clean checkout. A caller
221        // comparing this against the revision genesis recorded needs the
222        // absence to be visible, not papered over.
223        "public_revision": PUBLIC_REVISION,
224        "socket_path": socket_path().to_string_lossy(),
225    })
226}
227
228/// The socket every client verb uses.
229fn socket_path() -> PathBuf {
230    std::env::var_os(gwk_kernel::config::SOCKET_PATH_ENV)
231        .map(PathBuf::from)
232        .unwrap_or_else(|| PathBuf::from(gwk_kernel::DEFAULT_SOCKET_PATH))
233}
234
235async fn connect() -> Result<Client, Failure> {
236    let (client, _ack) = Client::connect(&socket_path()).await?;
237    Ok(client)
238}
239
240/// One request, one answer, printed as the kernel produced it.
241async fn ask(request: KernelRequest, pretty: bool) -> Result<(), Failure> {
242    let result = connect().await?.ask(request).await?;
243    answer(result, pretty)
244}
245
246/// A result printed, or turned into the failure it is.
247fn answer(result: KernelResult, pretty: bool) -> Result<(), Failure> {
248    if let KernelResult::Error {
249        code,
250        message,
251        detail,
252    } = result
253    {
254        let mut failure = Failure::new(code, message);
255        if let Some(detail) = detail {
256            // Kept, because the contract puts machine-readable specifics there —
257            // the version behind a stale_version, the field behind a validation.
258            failure.message = format!("{}: {detail}", failure.message);
259        }
260        return Err(failure);
261    }
262    let value = serde_json::to_value(&result)
263        .map_err(|e| Failure::internal(format!("render an answer: {e}")))?;
264    emit(&value, pretty);
265    Ok(())
266}
267
268/// Submit a command this program minted, in the kernel's own project.
269///
270/// The project scopes the idempotency key while the aggregate namespace is
271/// global, so a convenience verb naming one id does not need to know which
272/// project the aggregate belongs to — and a retry of the same verb is the same
273/// command. A caller who needs a different project writes the envelope itself
274/// and submits it with `gw command submit`.
275async fn submit(command: &KernelCommand, key: &str, pretty: bool) -> Result<(), Failure> {
276    let envelope = envelope(command, key, gwk_kernel::SYSTEM_PROJECT);
277    ask(KernelRequest::SubmitCommand { envelope }, pretty).await
278}
279
280fn envelope(command: &KernelCommand, key: &str, project: &str) -> CommandEnvelope {
281    CommandEnvelope {
282        command_id: CommandId::new(format!("cmd-{key}")),
283        project_id: ProjectId::new(project),
284        command_type: command.command_type().to_owned(),
285        schema_version: ENVELOPE_SCHEMA_VERSION,
286        // The CALLER's clock, which is what `issued_at` means. The kernel stamps
287        // its own `appended_at` from the database, and that one is authoritative
288        // for order — so these two disagreeing is information, not a conflict.
289        issued_at: now(),
290        actor: Actor {
291            kind: "operator".to_owned(),
292            id: None,
293        },
294        origin: Origin {
295            system: "gw".to_owned(),
296            r#ref: None,
297        },
298        target_aggregate_type: None,
299        target_aggregate_id: None,
300        expected_version: None,
301        idempotency_key: IdempotencyKey::new(key),
302        causation_id: None,
303        correlation_id: None,
304        // Infallible for a command built here — every variant serializes — and
305        // a null payload would be refused by the kernel anyway.
306        payload: serde_json::to_value(command).unwrap_or(Value::Null),
307    }
308}
309
310fn expect_command(envelope: &CommandEnvelope, wanted: &str) -> Result<(), Failure> {
311    if envelope.command_type == wanted {
312        return Ok(());
313    }
314    Err(Failure::usage(format!(
315        "this verb submits {wanted}, and the envelope is a {}",
316        envelope.command_type
317    )))
318}
319
320/// Follow the log until the stream ends or the daemon hangs up.
321///
322/// One JSON object per line, one line per event — not per batch. Batching is the
323/// transport's business, and a consumer that wants to resume reads
324/// `global_sequence` off the last line it managed to handle.
325async fn follow(cursor: Option<Seq>, pretty: bool) -> Result<(), Failure> {
326    let mut client = connect().await?;
327    let stream = client.subscribe(cursor).await?;
328    loop {
329        let Some(control) = client.receive().await? else {
330            // The daemon hung up. Not an error: a drain on shutdown looks
331            // exactly like this, and the caller has every event it printed.
332            return Ok(());
333        };
334        match control {
335            ServerControl::EventBatch {
336                request_id, events, ..
337            } if request_id == stream => {
338                for event in &events {
339                    let value = serde_json::to_value(event)
340                        .map_err(|e| Failure::internal(format!("render an event: {e}")))?;
341                    emit(&value, pretty);
342                }
343            }
344            ServerControl::StreamClosed {
345                request_id,
346                code,
347                last_cursor,
348            } if request_id == stream => {
349                // The cursor is what was DELIVERED, so it belongs in the message:
350                // it is the one piece of information that makes the next attempt
351                // gap-free.
352                let resume = last_cursor
353                    .map(|seq| seq.value().to_string())
354                    .unwrap_or_else(|| "the beginning".to_owned());
355                return Err(Failure::new(
356                    code,
357                    format!("the stream closed; resume from {resume}"),
358                ));
359            }
360            // Another subscription's traffic, or a response to a request this
361            // process never made. Not ours to interpret.
362            _ => {}
363        }
364    }
365}
366
367/// Upload one blob and print the descriptor the commit reported.
368async fn blob_put(source: &Source, media_type: String, pretty: bool) -> Result<(), Failure> {
369    let plaintext = bytes(source)?;
370    let address = address_of(&plaintext);
371    let mut client = connect().await?;
372
373    let upload = match client
374        .ask(KernelRequest::BlobBegin {
375            media_type,
376            byte_size: ByteCount::new(plaintext.len() as u64),
377        })
378        .await?
379    {
380        KernelResult::BlobBegun { upload_id } => upload_id,
381        other => return answer(other, pretty),
382    };
383
384    // An empty blob still writes one chunk. `chunks` yields nothing for an empty
385    // slice, and an upload that never wrote anything is not the same thing to the
386    // store as one that wrote nothing.
387    let pieces: Vec<&[u8]> = if plaintext.is_empty() {
388        vec![&[]]
389    } else {
390        plaintext.chunks(BLOB_CHUNK_BYTES).collect()
391    };
392    for (sequence, chunk) in pieces.into_iter().enumerate() {
393        let sequence = u32::try_from(sequence)
394            .map_err(|_| Failure::usage("this blob has more chunks than the protocol counts"))?;
395        match client
396            .ask(KernelRequest::BlobChunk {
397                upload_id: upload.clone(),
398                sequence,
399                data_base64: BASE64_STANDARD.encode(chunk),
400            })
401            .await?
402        {
403            KernelResult::BlobChunkAccepted { .. } => {}
404            other => return answer(other, pretty),
405        }
406    }
407
408    let result = client
409        .ask(KernelRequest::BlobCommit {
410            upload_id: upload,
411            address,
412        })
413        .await?;
414    answer(result, pretty)
415}
416
417/// Read one blob whole, a frame at a time.
418async fn blob_get(address: &BlobAddress, output: &Sink, pretty: bool) -> Result<(), Failure> {
419    let mut client = connect().await?;
420    // Its size first, because a read is clamped to one chunk and the loop has to
421    // know when it is done. `stat` is also what distinguishes an absent blob from
422    // a shredded one before any bytes move.
423    let size = match client
424        .ask(KernelRequest::BlobStat {
425            address: address.clone(),
426        })
427        .await?
428    {
429        KernelResult::BlobStat { descriptor } => descriptor.byte_size.value(),
430        other => return answer(other, pretty),
431    };
432
433    let mut bytes: Vec<u8> = Vec::with_capacity(size as usize);
434    while (bytes.len() as u64) < size {
435        let result = client
436            .ask(KernelRequest::BlobRead {
437                address: address.clone(),
438                offset: ByteCount::new(bytes.len() as u64),
439                length: ByteCount::new(size - bytes.len() as u64),
440            })
441            .await?;
442        let KernelResult::BlobBytes { data_base64, .. } = result else {
443            return answer(result, pretty);
444        };
445        let part = BASE64_STANDARD
446            .decode(&data_base64)
447            .map_err(|e| Failure::new(KernelErrorCode::BlobIntegrity, format!("base64: {e}")))?;
448        if part.is_empty() {
449            return Err(Failure::new(
450                KernelErrorCode::BlobIntegrity,
451                format!("the read stalled at {} of {size} bytes", bytes.len()),
452            ));
453        }
454        bytes.extend_from_slice(&part);
455    }
456
457    match output {
458        // Raw bytes, and nothing else — a caller piping a blob somewhere does
459        // not want a JSON object in the middle of it.
460        Sink::Stdout => tokio::io::stdout()
461            .write_all(&bytes)
462            .await
463            .map_err(|e| Failure::internal(format!("write to standard output: {e}")))?,
464        Sink::File(path) => {
465            write_file(path, &bytes)?;
466            emit(
467                &serde_json::json!({
468                    "type": "blob_written",
469                    "address": address.as_str(),
470                    "byte_size": size.to_string(),
471                    "path": path.to_string_lossy(),
472                }),
473                pretty,
474            );
475        }
476    }
477    Ok(())
478}
479
480/// A JSON document from a file or standard input, decoded into `T`.
481///
482/// The contract types refuse unknown fields, so a document with a stray key is a
483/// refusal here rather than a field the kernel silently ignored.
484fn document<T: serde::de::DeserializeOwned>(source: &Source) -> Result<T, Failure> {
485    let raw = bytes(source)?;
486    serde_json::from_slice(&raw).map_err(|e| Failure::usage(format!("{source:?}: {e}")))
487}
488
489fn bytes(source: &Source) -> Result<Vec<u8>, Failure> {
490    match source {
491        Source::Stdin => {
492            use std::io::Read as _;
493            let mut buffer = Vec::new();
494            std::io::stdin()
495                .read_to_end(&mut buffer)
496                .map_err(|e| Failure::usage(format!("read standard input: {e}")))?;
497            Ok(buffer)
498        }
499        Source::File(path) => {
500            std::fs::read(path).map_err(|e| Failure::usage(format!("read {}: {e}", path.display())))
501        }
502    }
503}
504
505fn write_file(path: &Path, bytes: &[u8]) -> Result<(), Failure> {
506    std::fs::write(path, bytes)
507        .map_err(|e| Failure::usage(format!("write {}: {e}", path.display())))
508}
509
510/// The address a blob will have: a digest over its PLAINTEXT, which is what
511/// makes deduplication work across encryptions of the same content.
512fn address_of(plaintext: &[u8]) -> BlobAddress {
513    // `hex_lower` returns 64 lowercase hex characters, which is exactly what
514    // `from_digest` accepts, so this cannot fail. The fallback that used to sit
515    // here — parsing `"sha256:"` — was the worse of the two: it is itself an
516    // error, so a broken invariant would have aborted on the SECOND failure
517    // with "unreachable" instead of naming the first.
518    BlobAddress::from_digest(&hex_lower(plaintext)).expect("a sha256 digest is 64 lowercase hex")
519}
520
521fn digest_of_json(value: &Value) -> String {
522    hex_lower(serde_json::to_string(value).unwrap_or_default().as_bytes())
523}
524
525fn hex_lower(bytes: &[u8]) -> String {
526    use sha2::{Digest as _, Sha256};
527    let digest: [u8; 32] = Sha256::digest(bytes).into();
528    let mut out = String::with_capacity(64);
529    for byte in digest {
530        // Two lowercase hex digits, without a formatting dependency.
531        out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
532        out.push(char::from_digit(u32::from(byte & 0x0f), 16).unwrap_or('0'));
533    }
534    out
535}
536
537/// Now, as RFC 3339 in UTC.
538///
539/// Written out rather than taken from a date library, because this is the only
540/// place the CLI needs a calendar and the conversion is a dozen lines. UTC only:
541/// a local offset in an `issued_at` would be a second thing to reconcile against
542/// the kernel's own UTC-pinned clock.
543fn now() -> Timestamp {
544    let secs = std::time::SystemTime::now()
545        .duration_since(std::time::UNIX_EPOCH)
546        .map(|d| d.as_secs())
547        .unwrap_or_default();
548    Timestamp::new(rfc3339(secs))
549}
550
551fn rfc3339(secs: u64) -> String {
552    let days = (secs / 86_400) as i64;
553    let rest = secs % 86_400;
554    let (year, month, day) = civil_from_days(days);
555    format!(
556        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
557        rest / 3600,
558        (rest % 3600) / 60,
559        rest % 60
560    )
561}
562
563/// Days since the epoch to a civil date, by Howard Hinnant's algorithm — the
564/// standard one, shifted to an era starting in March so a leap day lands last.
565fn civil_from_days(days: i64) -> (i64, u32, u32) {
566    let z = days + 719_468;
567    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
568    let doe = z - era * 146_097;
569    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
570    let y = yoe + era * 400;
571    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
572    let mp = (5 * doy + 2) / 153;
573    let d = doy - (153 * mp + 2) / 5 + 1;
574    let m = if mp < 10 { mp + 3 } else { mp - 9 };
575    (if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn the_clock_renders_dates_a_database_will_accept() {
584        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
585        assert_eq!(rfc3339(86_399), "1970-01-01T23:59:59Z");
586        assert_eq!(rfc3339(86_400), "1970-01-02T00:00:00Z");
587        // The case a naive conversion gets wrong: a leap day in a century year
588        // that IS a leap year.
589        assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
590        assert_eq!(rfc3339(951_868_800), "2000-03-01T00:00:00Z");
591        // And a century year that is NOT a leap year: February ends at the 28th
592        // and the next day is March, with no 29th in between.
593        assert_eq!(rfc3339(4_107_456_000), "2100-02-28T00:00:00Z");
594        assert_eq!(rfc3339(4_107_456_000 + 86_400), "2100-03-01T00:00:00Z");
595        // Monotonic as text, which is what makes these sortable at all.
596        assert!(rfc3339(0) < rfc3339(86_400));
597    }
598
599    #[test]
600    fn a_blob_address_is_the_digest_of_its_own_plaintext() {
601        // The empty-string SHA-256, which is the one digest worth hard-coding:
602        // it catches a hex table with its nibbles the wrong way round, which a
603        // round-trip test would not.
604        assert_eq!(
605            address_of(b"").as_str(),
606            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
607        );
608        assert_ne!(address_of(b"a").as_str(), address_of(b"b").as_str());
609    }
610
611    #[test]
612    fn a_minted_envelope_is_the_same_envelope_on_a_retry() {
613        let command = KernelCommand::ResolveAttention {
614            attention_item_id: AttentionItemId::new("a-1"),
615            resolution: None,
616        };
617        let one = envelope(&command, "resolve_attention:a-1", "system");
618        let two = envelope(&command, "resolve_attention:a-1", "system");
619        // Everything but the clock: the id and the key are derived from the
620        // request, which is what makes a repeated `gw attention resolve` land
621        // once rather than twice.
622        assert_eq!(one.command_id, two.command_id);
623        assert_eq!(one.idempotency_key, two.idempotency_key);
624        assert_eq!(one.command_type, "resolve_attention");
625        assert_eq!(one.payload, two.payload);
626    }
627
628    #[test]
629    fn a_verb_that_promises_one_command_refuses_another() {
630        let command = KernelCommand::ResolveAttention {
631            attention_item_id: AttentionItemId::new("a-1"),
632            resolution: None,
633        };
634        let envelope = envelope(&command, "k", "system");
635        assert!(expect_command(&envelope, "resolve_attention").is_ok());
636        let wrong = expect_command(&envelope, "grant_authority").expect_err("refused");
637        assert_eq!(wrong.exit, exit::USAGE);
638    }
639}