max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+320 insertions,
-0 deletions
| @@ -2405,6 +2405,326 @@ | |||
| 2405 | 2405 | .count(); | |
| 2406 | 2406 | assert_eq!(notary_calls, 3, "the retry is bounded at three attempts"); | |
| 2407 | 2407 | } | |
| 2408 | + | // ---- item (5): SSH and Agent transports end to end, via a recording fake ---- | |
| 2409 | + | // | |
| 2410 | + | // Every other test declares `ssh = "local"`, so the runner's two-plane | |
| 2411 | + | // routing is only exercised at construction | |
| 2412 | + | // (state::agent_host_syncs_over_ssh_never_the_agent): steps run over the | |
| 2413 | + | // EXEC transport (state.executors -- SshExec, or the in-session AgentRpc for | |
| 2414 | + | // a mac host) while artifacts move over the SYNC transport (state.syncs -- | |
| 2415 | + | // always ssh, NEVER the agent, whose confined `/pull` would 404 or force | |
| 2416 | + | // `pull_root` wide enough to expose `~/.tauri/passwords.env`). These drive a | |
| 2417 | + | // real recipe through `start_build` on a NON-local topology, replace the | |
| 2418 | + | // real ssh/agent transports with a recording fake, and assert which plane | |
| 2419 | + | // handled which operation -- the runtime form of that construction-time | |
| 2420 | + | // invariant, on the ssh string every other test pins to "local". | |
| 2421 | + | ||
| 2422 | + | /// Records shell commands and artifact pulls on separate logs, so a test can | |
| 2423 | + | /// prove the exec transport built/signed and the sync transport collected -- | |
| 2424 | + | /// and that neither did the other's job -- without a live host or ssh. | |
| 2425 | + | struct RecordingExec { | |
| 2426 | + | caps: CapabilitySet, | |
| 2427 | + | commands: Arc<std::sync::Mutex<Vec<String>>>, | |
| 2428 | + | pulls: Arc<std::sync::Mutex<Vec<String>>>, | |
| 2429 | + | } | |
| 2430 | + | ||
| 2431 | + | impl RecordingExec { | |
| 2432 | + | fn new() -> Arc<Self> { | |
| 2433 | + | Arc::new(Self { | |
| 2434 | + | // A mac build host's real grant. Nothing in this fake gates on it | |
| 2435 | + | // (the real transports do), but keep it coherent so | |
| 2436 | + | // `capabilities()` is not a lie. | |
| 2437 | + | caps: CapabilitySet::from_tokens( | |
| 2438 | + | ["build", "sign", "notarize", "staple"], | |
| 2439 | + | ["build-log", "artifact"], | |
| 2440 | + | ), | |
| 2441 | + | commands: Arc::new(std::sync::Mutex::new(Vec::new())), | |
| 2442 | + | pulls: Arc::new(std::sync::Mutex::new(Vec::new())), | |
| 2443 | + | }) | |
| 2444 | + | } | |
| 2445 | + | fn commands(&self) -> Vec<String> { | |
| 2446 | + | self.commands.lock().unwrap().clone() | |
| 2447 | + | } | |
| 2448 | + | fn pulls(&self) -> Vec<String> { | |
| 2449 | + | self.pulls.lock().unwrap().clone() | |
| 2450 | + | } | |
| 2451 | + | } | |
| 2452 | + | ||
| 2453 | + | #[async_trait] | |
| 2454 | + | impl Executor for RecordingExec { | |
| 2455 | + | async fn run_streaming( | |
| 2456 | + | &self, | |
| 2457 | + | step: &ops_exec::Step, | |
| 2458 | + | _sink: &mut dyn LogSink, | |
| 2459 | + | ) -> anyhow::Result<RunOutput> { | |
| 2460 | + | self.commands | |
| 2461 | + | .lock() | |
| 2462 | + | .unwrap() | |
| 2463 | + | .push(step.argv.last().cloned().unwrap_or_default()); | |
| 2464 | + | Ok(RunOutput { | |
| 2465 | + | status: std::process::ExitStatus::from_raw(0), | |
| 2466 | + | stdout: Vec::new(), | |
| 2467 | + | stderr: Vec::new(), | |
| 2468 | + | }) | |
| 2469 | + | } | |
| 2470 | + | async fn pull_file( | |
| 2471 | + | &self, | |
| 2472 | + | r: &std::path::Path, | |
| 2473 | + | _l: &std::path::Path, | |
| 2474 | + | _o: &SyncOpts, | |
| 2475 | + | ) -> anyhow::Result<()> { | |
| 2476 | + | self.pulls | |
| 2477 | + | .lock() | |
| 2478 | + | .unwrap() | |
| 2479 | + | .push(r.to_string_lossy().into_owned()); | |
| 2480 | + | Ok(()) | |
| 2481 | + | } | |
| 2482 | + | async fn pull_dir( | |
| 2483 | + | &self, | |
| 2484 | + | r: &std::path::Path, | |
| 2485 | + | _l: &std::path::Path, | |
| 2486 | + | _o: &SyncOpts, | |
| 2487 | + | ) -> anyhow::Result<()> { | |
| 2488 | + | self.pulls | |
| 2489 | + | .lock() | |
| 2490 | + | .unwrap() | |
| 2491 | + | .push(r.to_string_lossy().into_owned()); | |
| 2492 | + | Ok(()) | |
| 2493 | + | } | |
| 2494 | + | async fn pull_glob( | |
| 2495 | + | &self, | |
| 2496 | + | g: &str, | |
| 2497 | + | _l: &std::path::Path, | |
| 2498 | + | _o: &SyncOpts, | |
| 2499 | + | ) -> anyhow::Result<()> { | |
| 2500 | + | self.pulls.lock().unwrap().push(g.to_string()); | |
| 2501 | + | Ok(()) | |
| 2502 | + | } | |
| 2503 | + | async fn push_dir( | |
| 2504 | + | &self, | |
| 2505 | + | _l: &std::path::Path, | |
| 2506 | + | _r: &std::path::Path, | |
| 2507 | + | _o: &SyncOpts, | |
| 2508 | + | ) -> anyhow::Result<()> { | |
| 2509 | + | Ok(()) | |
| 2510 | + | } | |
| 2511 | + | async fn preflight(&self) -> anyhow::Result<()> { | |
| 2512 | + | Ok(()) | |
| 2513 | + | } | |
| 2514 | + | fn capabilities(&self) -> &CapabilitySet { | |
| 2515 | + | &self.caps | |
| 2516 | + | } | |
| 2517 | + | } | |
| 2518 | + | ||
| 2519 | + | /// Stand up a single-target app (host named `h1`) whose recipe is | |
| 2520 | + | /// `recipe_body` (with `REPO` replaced by the checkout path), inject | |
| 2521 | + | /// `exec_fake` as the host's EXEC transport and `sync_fake` as its SYNC | |
| 2522 | + | /// transport, run the build to a terminal state, and return the tmpdir plus | |
| 2523 | + | /// `(status, error)`. Unlike `test_state`'s `build_executors`, this replaces | |
| 2524 | + | /// BOTH planes so no real ssh/agent transport is dialed. The returned | |
| 2525 | + | /// [`tempfile::TempDir`] holds the sqlite DB and must outlive the caller's | |
| 2526 | + | /// assertions. | |
| 2527 | + | async fn run_two_plane( | |
| 2528 | + | host_toml: &str, | |
| 2529 | + | target: &str, | |
| 2530 | + | recipe_file: &str, | |
| 2531 | + | recipe_body: &str, | |
| 2532 | + | exec_fake: Arc<dyn Executor>, | |
| 2533 | + | sync_fake: Arc<dyn Executor>, | |
| 2534 | + | ) -> (tempfile::TempDir, String, String) { | |
| 2535 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 2536 | + | let root = tmp.path(); | |
| 2537 | + | let repo = root.join("app"); | |
| 2538 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 2539 | + | std::fs::write( | |
| 2540 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 2541 | + | r#"{"version":"0.0.1"}"#, | |
| 2542 | + | ) | |
| 2543 | + | .unwrap(); | |
| 2544 | + | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 2545 | + | std::fs::write( | |
| 2546 | + | repo.join("dist/recipes").join(recipe_file), | |
| 2547 | + | recipe_body.replace("REPO", repo.to_str().unwrap()), | |
| 2548 | + | ) | |
| 2549 | + | .unwrap(); | |
| 2550 | + | std::fs::write( | |
| 2551 | + | repo.join("bento.toml"), | |
| 2552 | + | format!("targets = [\"{target}\"]\n"), | |
| 2553 | + | ) | |
| 2554 | + | .unwrap(); | |
| 2555 | + | ||
| 2556 | + | let cfg = Config::for_tests(root); | |
| 2557 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 2558 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 2559 | + | "{}\n[app.demo]\nrepo = \"{}\"\n", | |
| 2560 | + | host_toml.replace("REPO", repo.to_str().unwrap()), | |
| 2561 | + | repo.display() | |
| 2562 | + | )) | |
| 2563 | + | .unwrap(); | |
| 2564 | + | ||
| 2565 | + | let mut state = test_state(pool.clone(), topo, cfg); | |
| 2566 | + | state.executors = Arc::new(HashMap::from([("h1".to_string(), exec_fake)])); | |
| 2567 | + | state.syncs = Arc::new(HashMap::from([("h1".to_string(), sync_fake)])); | |
| 2568 | + | ||
| 2569 | + | let build_id = start_build( | |
| 2570 | + | state.clone(), | |
| 2571 | + | AppId::new("demo"), | |
| 2572 | + | Version::parse("0.0.1").unwrap(), | |
| 2573 | + | vec![target.parse().unwrap()], | |
| 2574 | + | ) | |
| 2575 | + | .await | |
| 2576 | + | .unwrap(); | |
| 2577 | + | ||
| 2578 | + | let mut status = String::new(); | |
| 2579 | + | let mut error = String::new(); | |
| 2580 | + | for _ in 0..100 { | |
| 2581 | + | let row: Option<(String, Option<String>)> = | |
| 2582 | + | sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?") | |
| 2583 | + | .bind(build_id) | |
| 2584 | + | .fetch_optional(&pool) | |
| 2585 | + | .await | |
| 2586 | + | .unwrap(); | |
| 2587 | + | if let Some((s, e)) = row { | |
| 2588 | + | status = s; | |
| 2589 | + | error = e.unwrap_or_default(); | |
| 2590 | + | if status != "running" { | |
| 2591 | + | break; | |
| 2592 | + | } | |
| 2593 | + | } | |
| 2594 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 2595 | + | } | |
| 2596 | + | (tmp, status, error) | |
| 2597 | + | } | |
| 2598 | + | ||
| 2599 | + | /// An agent (macOS) host signs over the AGENT transport but is collected | |
| 2600 | + | /// from over SSH -- driven through the whole runner, not just `build_sync`. | |
| 2601 | + | /// The sign chain's commands land on the exec plane and the artifact pull on | |
| 2602 | + | /// the sync plane; crucially, the agent plane is asked to move NOTHING (a | |
| 2603 | + | /// regression to one transport would route collect at `AgentRpc::pull_glob`, | |
| 2604 | + | /// refused by design, or widen `pull_root` over the secret-bearing home dir). | |
| 2605 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 2606 | + | async fn an_agent_host_signs_over_the_agent_and_collects_over_ssh_end_to_end() { | |
| 2607 | + | let agent = RecordingExec::new(); // exec plane (AgentRpc in prod) | |
| 2608 | + | let ssh = RecordingExec::new(); // sync plane (SshExec in prod) | |
| 2609 | + | let host = r#" | |
| 2610 | + | [[host]] | |
| 2611 | + | name = "h1" | |
| 2612 | + | ssh = "mbp" | |
| 2613 | + | targets = ["macos/aarch64"] | |
| 2614 | + | transport = "agent" | |
| 2615 | + | agent_url = "http://mbp:8765" | |
| 2616 | + | actuate = ["build", "sign", "notarize", "staple"] | |
| 2617 | + | observe = ["build-log", "gatekeeper", "artifact"] | |
| 2618 | + | pull_root = "REPO" | |
| 2619 | + | "#; | |
| 2620 | + | let recipe = r#" | |
| 2621 | + | let h = build_host(); | |
| 2622 | + | step("build"); | |
| 2623 | + | sh_ok(h, "echo built"); | |
| 2624 | + | step("sign"); | |
| 2625 | + | codesign(h, "Developer ID Application: Test", "REPO/out/demo.dmg"); | |
| 2626 | + | step("collect"); | |
| 2627 | + | collect(h, "REPO/out/*.dmg", "demo", "0.0.1"); | |
| 2628 | + | "#; | |
| 2629 | + | let (_tmp, status, error) = run_two_plane( | |
| 2630 | + | host, | |
| 2631 | + | "macos/aarch64", | |
| 2632 | + | "macos.rhai", | |
| 2633 | + | recipe, | |
| 2634 | + | agent.clone(), | |
| 2635 | + | ssh.clone(), | |
| 2636 | + | ) | |
| 2637 | + | .await; | |
| 2638 | + | assert_eq!(status, "ok", "the recipe should complete: {error}"); | |
| 2639 | + | ||
| 2640 | + | // The build + sign commands ran on the AGENT (exec) transport. | |
| 2641 | + | let agent_cmds = agent.commands(); | |
| 2642 | + | assert!( | |
| 2643 | + | agent_cmds.iter().any(|c| c.contains("codesign")), | |
| 2644 | + | "codesign rides the agent exec transport: {agent_cmds:?}" | |
| 2645 | + | ); | |
| 2646 | + | assert!( | |
| 2647 | + | agent_cmds.iter().any(|c| c.contains("echo built")), | |
| 2648 | + | "the build step rides the agent exec transport: {agent_cmds:?}" | |
| 2649 | + | ); | |
| 2650 | + | // ...and the agent moved NO artifacts. This is the load-bearing half: | |
| 2651 | + | // AgentRpc::pull_glob is refused by design, so collect must not touch it. | |
| 2652 | + | assert!( | |
| 2653 | + | agent.pulls().is_empty(), | |
| 2654 | + | "the agent transport must never collect artifacts: {:?}", | |
| 2655 | + | agent.pulls() | |
| 2656 | + | ); | |
| 2657 | + | ||
| 2658 | + | // The artifact was collected over the SSH (sync) transport... | |
| 2659 | + | let ssh_pulls = ssh.pulls(); | |
| 2660 | + | assert!( | |
| 2661 | + | ssh_pulls | |
| 2662 | + | .iter() | |
| 2663 | + | .any(|p| p.contains("demo.dmg") || p.contains("*.dmg")), | |
| 2664 | + | "collect rides the ssh sync transport: {ssh_pulls:?}" | |
| 2665 | + | ); | |
| 2666 | + | // ...and the sync transport was never asked to run a build/sign command. | |
| 2667 | + | assert!( | |
| 2668 | + | ssh.commands().is_empty(), | |
| 2669 | + | "the sync transport must never run host commands: {:?}", | |
| 2670 | + | ssh.commands() | |
| 2671 | + | ); | |
| 2672 | + | } | |
| 2673 | + | ||
| 2674 | + | /// A plain (non-agent) host whose `ssh` is a remote alias, not "local" -- | |
| 2675 | + | /// the case every other test avoids. The recipe runs end to end through the | |
| 2676 | + | /// fake, proving the runner drives a non-local host and still splits exec | |
| 2677 | + | /// (build) from sync (collect) across the two transport maps. | |
| 2678 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 2679 | + | async fn a_non_local_ssh_host_runs_a_recipe_end_to_end() { | |
| 2680 | + | let exec = RecordingExec::new(); | |
| 2681 | + | let sync = RecordingExec::new(); | |
| 2682 | + | let host = r#" | |
| 2683 | + | [[host]] | |
| 2684 | + | name = "h1" | |
| 2685 | + | ssh = "astra" | |
| 2686 | + | targets = ["linux/x86_64"] | |
| 2687 | + | pull_root = "REPO" | |
| 2688 | + | "#; | |
| 2689 | + | let recipe = r#" | |
| 2690 | + | let h = build_host(); | |
| 2691 | + | step("build"); | |
| 2692 | + | sh_ok(h, "echo compiling"); | |
| 2693 | + | step("collect"); | |
| 2694 | + | collect(h, "REPO/out/demo.bin", "demo", "0.0.1"); | |
| 2695 | + | "#; | |
| 2696 | + | let (_tmp, status, error) = run_two_plane( | |
| 2697 | + | host, | |
| 2698 | + | "linux/x86_64", | |
| 2699 | + | "linux.rhai", | |
| 2700 | + | recipe, | |
| 2701 | + | exec.clone(), | |
| 2702 | + | sync.clone(), | |
| 2703 | + | ) | |
| 2704 | + | .await; | |
| 2705 | + | assert_eq!(status, "ok", "the recipe should complete: {error}"); | |
| 2706 | + | ||
| 2707 | + | assert!( | |
| 2708 | + | exec.commands().iter().any(|c| c.contains("echo compiling")), | |
| 2709 | + | "the build command rides the exec transport: {:?}", | |
| 2710 | + | exec.commands() | |
| 2711 | + | ); | |
| 2712 | + | assert!( | |
| 2713 | + | exec.pulls().is_empty(), | |
| 2714 | + | "the exec transport must not collect: {:?}", | |
| 2715 | + | exec.pulls() | |
| 2716 | + | ); | |
| 2717 | + | assert!( | |
| 2718 | + | sync.pulls().iter().any(|p| p.contains("demo.bin")), | |
| 2719 | + | "collect rides the sync transport: {:?}", | |
| 2720 | + | sync.pulls() | |
| 2721 | + | ); | |
| 2722 | + | assert!( | |
| 2723 | + | sync.commands().is_empty(), | |
| 2724 | + | "the sync transport must not run commands: {:?}", | |
| 2725 | + | sync.commands() | |
| 2726 | + | ); | |
| 2727 | + | } | |
| 2408 | 2728 | } | |
| 2409 | 2729 | ||
| 2410 | 2730 | /// Every recipe of every configured app must parse. |