Skip to main content

max / makenotwork

Let a service hand off instead of installing itself pom is the first product meant to be built by Bento and deployed by Sando, and Bento had no way to express that. `kind = "service"` required `[[deploy]]` entries ("a service that lands nowhere has no release"), and the other two kinds are wrong for different reasons: a library means a crates.io path, and an app's `verify` resolves to a Gatekeeper observe, which a Linux build host can never honestly hold a grant for. The kind says what the thing IS — run, not distributed — and delivery is a separate question with two answers: `[[deploy]]` in the app's manifest, or a `[handoff.<app>]` in the daemon config. Exactly one, never both: - Neither is the original failure, a build that archives into nothing while looking green. - Both is the more interesting one. Under the boundary, deciding whether a thing advances a stage is Sando's job, so a service that also installs itself has two systems with an opinion about what is running and no rule for which wins. The check cannot live in `validate_deploy`: the manifest is in the app's repo and the handoff is in the daemon's config, and `Topology` only parses the first. It is a cross-document invariant, so it runs where both are in hand — `main`, at startup and under `--check-config`, which is what a self-update runs before swapping the binary in. Nothing changes for any app today. Verified against the live config: 4 hosts, 14 apps, pom still a self-deploying service. Flipping pom is a separate, deliberate step, because its manifest change and its `[handoff.pom]` table have to land together or a release would build and never deploy.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 02:36 UTC
Signed with PGP, not checked
Commit: f660e0881fbb2fb18e6691cbf3db6752bb1ee66f
Parent: 2feeccb
2 files changed, +133 insertions, -17 deletions
@@ -21,6 +21,10 @@
21 21 if std::env::args().skip(1).any(|a| a == "--check-config") {
22 22 let cfg = config::Config::load()?;
23 23 let topo = topology::Topology::load(&cfg.topology_path)?;
24 + // Cross-document: every service reaches its host exactly one way, either
25 + // its own [[deploy]] or a [handoff] to Sando. Neither builds and archives
26 + // into nothing; both leaves two systems installing the same service.
27 + topo.validate_delivery(&cfg)?;
24 28 println!(
25 29 "bentod config OK: {} host(s), {} app(s) from {}",
26 30 topo.hosts.len(),
@@ -40,6 +44,7 @@
40 44
41 45 let cfg = Arc::new(config::Config::load()?);
42 46 let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
47 + topo.validate_delivery(&cfg)?;
43 48 tokio::fs::create_dir_all(&cfg.dist_root).await?;
44 49 tokio::fs::create_dir_all(&cfg.logs_root).await?;
45 50 let pool = db::open(&cfg.db_path).await?;
@@ -69,15 +69,30 @@
69 69 /// uploads from one host — so it runs a single `publish.rhai` rather than a
70 70 /// recipe per platform, and its `targets` name the host that does it.
71 71 Library,
72 - /// A long-running binary Bento ships onto the hosts that run it, rather than
73 - /// distributing to users. Built per target like an app, but it ends at
74 - /// `deploy` instead of `collect`: no bundle, no signature for a human to
75 - /// check, no registry. Where each target lands is `[[deploy]]` in the app's own manifest.
72 + /// A long-running binary that is run rather than distributed to users: no
73 + /// bundle, no signature for a human to check, no registry. Built per target
74 + /// like an app.
75 + ///
76 + /// The kind says what the thing IS, not who delivers it, and there are two
77 + /// ways it reaches the host that runs it:
78 + ///
79 + /// - `[[deploy]]` in the app's own manifest — Bento installs it and restarts
80 + /// the unit, so the release ends at `deploy`.
81 + /// - A `[handoff.<app>]` in the daemon config — Bento ends at `collect` and
82 + /// hands the artifact to a Sando, which decides whether it advances a stage
83 + /// (wiki `sando-bento-boundary`).
84 + ///
85 + /// Exactly one, enforced by [`Topology::validate_delivery`]: neither is a
86 + /// build that archives into nothing, both is two systems installing one
87 + /// service with no rule for which wins.
76 88 ///
77 89 /// A separate kind rather than an app with an extra step, because the two
78 90 /// differ in what `verify` means. An app's verify is Gatekeeper (is this
79 91 /// bundle notarized); a service's is running the toolchain against the
80 92 /// binary it just built — the same thing a library's crate preflight does.
93 + /// That is also why a handed-off service is not simply `kind = "app"`: an
94 + /// app's `verify` would demand a Gatekeeper grant from a Linux build host
95 + /// that can never honestly hold one.
81 96 Service,
82 97 }
83 98
@@ -295,11 +310,17 @@
295 310 /// is the earlier, friendlier half of the same rule.
296 311 fn validate_deploy(name: &str, app: &AppConfig) -> Result<()> {
297 312 if app.deploy.is_empty() {
298 - anyhow::ensure!(
299 - app.kind != Kind::Service,
300 - "app `{name}` is a service but declares no [[deploy]] entries — \
301 - a service that lands nowhere has no release"
302 - );
313 + // A service with no `[[deploy]]` used to be rejected outright. It is
314 + // legal now, and means "somebody else installs this" — the Sando/Bento
315 + // boundary, where Bento builds and packages and Sando decides whether a
316 + // thing advances a stage. What is NOT legal is a service that neither
317 + // deploys nor hands off, which is still "a service that lands nowhere".
318 + //
319 + // That second half cannot be checked here: it depends on the daemon's
320 + // `[handoff]` tables, and this function only knows the app manifest. It
321 + // is a cross-document invariant, so it lives where both documents are in
322 + // hand — [`Topology::validate_delivery`], called at startup and by
323 + // `--check-config`.
303 324 return Ok(());
304 325 }
305 326 anyhow::ensure!(
@@ -417,6 +438,46 @@
417 438 Self::resolve(toml::from_str(s)?)
418 439 }
419 440
441 + /// Every service reaches the box that runs it exactly one way.
442 + ///
443 + /// A service either installs itself (`[[deploy]]` in its own manifest) or is
444 + /// handed to a Sando that does (`[handoff.<app>]` in the daemon config).
445 + /// Neither is the original "a service that lands nowhere has no release":
446 + /// it would build, archive, and stop, looking green while nothing shipped.
447 + ///
448 + /// Both is refused too, and that is the more useful half. Under the boundary
449 + /// (wiki `sando-bento-boundary`) deciding whether a thing advances a stage is
450 + /// Sando's job, so a service that also installs itself has two systems with
451 + /// an opinion about what is running and no rule for which wins. Better to
452 + /// fail at startup than to discover it when a promote and a recipe disagree.
453 + ///
454 + /// Cross-document, so it cannot live in `validate()`: the manifest is in the
455 + /// app's repo and the handoff is in the daemon's config, and `Topology` only
456 + /// parses the first. Called from `main` after both are loaded, which is also
457 + /// what `--check-config` runs.
458 + pub fn validate_delivery(&self, cfg: &crate::config::Config) -> Result<()> {
459 + for (name, app) in &self.app {
460 + if app.kind != Kind::Service {
461 + continue;
462 + }
463 + let hands_off = cfg.handoff.contains_key(name.as_str());
464 + match (app.deploy.is_empty(), hands_off) {
465 + (true, false) => anyhow::bail!(
466 + "app `{name}` is a service but neither declares [[deploy]] entries nor has \
467 + a [handoff.{name}] table in the daemon config — it would build and archive \
468 + and never reach the host that runs it"
469 + ),
470 + (false, true) => anyhow::bail!(
471 + "app `{name}` is a service that both declares [[deploy]] entries and has a \
472 + [handoff.{name}] table — it would be installed by Bento AND handed to Sando \
473 + to install. Pick one: Bento deploys it, or Sando does"
474 + ),
475 + _ => {}
476 + }
477 + }
478 + Ok(())
479 + }
480 +
420 481 fn validate(&self) -> Result<()> {
421 482 anyhow::ensure!(
422 483 !self.hosts.is_empty(),
@@ -684,25 +745,75 @@
684 745 assert_eq!(mac.port, None);
685 746 }
686 747
687 - /// The kind and the `[[deploy]]` table have to agree in both directions. An
688 - /// app with deploy entries would build them and never install them; a
689 - /// service without any would have no terminal step at all.
748 + /// `[[deploy]]` entries still mean "a service installs itself", so they stay
749 + /// refused on anything that is not a service.
750 + ///
751 + /// The other direction moved. A service with no `[[deploy]]` used to be
752 + /// rejected here; it is now legal at the manifest level and means "somebody
753 + /// else installs this", with `validate_delivery` deciding whether that
754 + /// somebody exists. This function only sees the manifest, so it cannot know.
690 755 #[test]
691 - fn kind_and_deploy_entries_must_agree() {
756 + fn deploy_entries_belong_only_to_a_service() {
692 757 let deploy = "\n[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
693 758 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
694 - // Deploy entries on a plain app.
695 759 assert!(
696 760 load_with(HOSTS, &format!("targets = [\"linux/x86_64\"]\n{deploy}")).is_err(),
697 761 "only a service installs onto a host"
698 762 );
699 - // A service with none.
700 763 assert!(
701 - load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").is_err(),
702 - "a service that lands nowhere has no release"
764 + load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").is_ok(),
765 + "a service with no [[deploy]] parses; whether it hands off is validate_delivery's call"
703 766 );
704 767 }
705 768
769 + /// A service reaches its host exactly one way. Neither route is a build that
770 + /// archives into nothing; both routes is two systems installing one service
771 + /// with no rule for which wins.
772 + #[test]
773 + fn a_service_must_deploy_itself_or_hand_off_but_not_both() {
774 + let tmp = tempfile::tempdir().unwrap();
775 + let mut cfg = crate::config::Config::for_tests(tmp.path());
776 +
777 + let (handing_off, _keep) =
778 + load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").unwrap();
779 + let name = handing_off.app.keys().next().unwrap().clone();
780 +
781 + // Neither: refused, and the message says what would have happened.
782 + let err = handing_off.validate_delivery(&cfg).unwrap_err();
783 + assert!(
784 + format!("{err:#}").contains("never reach the host"),
785 + "{err:#}"
786 + );
787 +
788 + // Handed off: fine. This is pom under the boundary.
789 + cfg.handoff.insert(
790 + name.clone(),
791 + crate::config::Handoff {
792 + host: "local".into(),
793 + staging_root: "/srv/sando/staging".into(),
794 + url: "http://127.0.0.1:7766".into(),
795 + sando_app: None,
796 + token_file: None,
797 + },
798 + );
799 + handing_off.validate_delivery(&cfg).unwrap();
800 +
801 + // Both: refused. Bento would install it and Sando would too.
802 + let (self_deploying, _keep2) = load_with(
803 + HOSTS,
804 + "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
805 + [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
806 + install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
807 + )
808 + .unwrap();
809 + let err = self_deploying.validate_delivery(&cfg).unwrap_err();
810 + assert!(format!("{err:#}").contains("Pick one"), "{err:#}");
811 +
812 + // And a non-service is never subject to any of it.
813 + let (plain, _keep3) = load_with(HOSTS, "targets = [\"linux/x86_64\"]\n").unwrap();
814 + plain.validate_delivery(&cfg).unwrap();
815 + }
816 +
706 817 /// Every target a service ships must say where it lands. Without this a
707 818 /// half-configured service builds both arches and silently installs one.
708 819 #[test]