1use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use clap::{Args as ClapArgs, Subcommand, ValueEnum};
14use serde::Serialize;
15
16use crate::cli::output;
17use crate::shared::error::{Error, ErrorCode};
18
19const DOCKERFILE: &str = include_str!("../../../container/docker/Dockerfile");
24const INSTALL_BACKENDS: &str = include_str!("../../../container/docker/install-backends.sh");
25const ENTRYPOINT: &str = include_str!("../../../container/docker/entrypoint.sh");
26
27const VERSION: &str = env!("CARGO_PKG_VERSION");
29const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
32const DEFAULT_NAME: &str = "afhttp-host";
34const IMAGE_REPO: &str = "afhttp-host";
35
36#[derive(ClapArgs, Debug)]
37pub struct Args {
38 #[command(subcommand)]
39 pub sub: ContainerSub,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum ContainerSub {
44 Install(InstallArgs),
46 Uninstall(UninstallArgs),
48 Status(StatusArgs),
50 Logs(LogsArgs),
52}
53
54#[derive(ClapArgs, Debug)]
56pub struct CommonArgs {
57 #[arg(long, value_enum)]
59 pub runtime: Option<Runtime>,
60 #[arg(long, default_value = DEFAULT_NAME)]
62 pub name: String,
63}
64
65#[derive(ClapArgs, Debug)]
66pub struct InstallArgs {
67 #[command(flatten)]
68 pub common: CommonArgs,
69 #[arg(long, default_value_t = 9222)]
71 pub port: u16,
72 #[arg(long, default_value = "work")]
74 pub profile: String,
75 #[arg(long = "shm-size", default_value = "1g")]
77 pub shm_size: String,
78 #[arg(long = "with", value_name = "BACKEND")]
81 pub with: Vec<String>,
82 #[arg(long)]
84 pub rebuild: bool,
85 #[arg(long = "from-source")]
88 pub from_source: bool,
89 #[arg(long, value_name = "DIR")]
92 pub context: Option<String>,
93 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
95 pub host_args: Vec<String>,
96}
97
98#[derive(ClapArgs, Debug)]
99pub struct UninstallArgs {
100 #[command(flatten)]
101 pub common: CommonArgs,
102 #[arg(long)]
104 pub purge: bool,
105}
106
107#[derive(ClapArgs, Debug)]
108pub struct StatusArgs {
109 #[command(flatten)]
110 pub common: CommonArgs,
111 #[arg(long, default_value_t = 9222)]
113 pub port: u16,
114}
115
116#[derive(ClapArgs, Debug)]
117pub struct LogsArgs {
118 #[command(flatten)]
119 pub common: CommonArgs,
120 #[arg(long, short = 'f')]
122 pub follow: bool,
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
128pub enum Runtime {
129 Docker,
130 Podman,
131 #[value(alias = "container")]
134 Apple,
135}
136
137impl Runtime {
138 fn bin(self) -> &'static str {
140 match self {
141 Runtime::Docker => "docker",
142 Runtime::Podman => "podman",
143 Runtime::Apple => "container",
144 }
145 }
146
147 fn label(self) -> &'static str {
149 match self {
150 Runtime::Docker => "docker",
151 Runtime::Podman => "podman",
152 Runtime::Apple => "apple",
153 }
154 }
155}
156
157pub async fn run(args: Args) -> Result<(), Error> {
158 match args.sub {
159 ContainerSub::Install(a) => install(a).await,
160 ContainerSub::Uninstall(a) => uninstall(a),
161 ContainerSub::Status(a) => status(a).await,
162 ContainerSub::Logs(a) => logs(a),
163 }
164}
165
166#[derive(Serialize)]
169struct InstallResult {
170 runtime: &'static str,
171 image: String,
172 container: String,
173 endpoint: String,
174 profile: String,
175 token: String,
176 client_command: String,
177 backends: Vec<String>,
178}
179
180async fn install(args: InstallArgs) -> Result<(), Error> {
181 let runtime = resolve_runtime(args.common.runtime)?;
182 let backends = resolve_backends(&args.with)?;
183 validate_install_args(&args, &backends)?;
184 let image = image_tag();
185
186 start_daemon(runtime);
187
188 if args.from_source {
191 let ctx = resolve_source_context(args.context.as_deref())?;
192 let build = build_args(
193 &image,
194 runtime,
195 BuildSource::FromSource { ctx: &ctx },
196 &backends,
197 );
198 exec_inherit(runtime.bin(), &build)?;
199 } else if args.rebuild || !image_exists(runtime, &image) {
200 let ctx = write_build_context()?;
201 let target = target_triple(runtime, std::env::consts::ARCH);
202 let build = build_args(
203 &image,
204 runtime,
205 BuildSource::Embedded { ctx: &ctx, target },
206 &backends,
207 );
208 exec_inherit(runtime.bin(), &build).map_err(|_| build_failed_error(target))?;
209 }
210 validate_container_image_host_args(runtime, &image, &args.host_args)?;
211
212 let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
215 let _ = capture(runtime.bin(), &["rm".into(), args.common.name.clone()]);
216
217 let run = run_args(
218 &args.common.name,
219 &image,
220 args.port,
221 &args.profile,
222 &args.shm_size,
223 &args.host_args,
224 );
225 exec_inherit(runtime.bin(), &run)?;
226
227 let token = read_token(runtime, &args.common.name).await?;
228 let endpoint = endpoint_url(args.port);
229 wait_for_container_health(runtime, &args.common.name, args.port, &token).await?;
230 output::emit(
231 "container_install",
232 &InstallResult {
233 runtime: runtime.label(),
234 image,
235 container: args.common.name.clone(),
236 endpoint,
237 profile: args.profile.clone(),
238 client_command: client_command(args.port, &token),
239 token,
240 backends: backends.iter().map(|b| b.name.to_string()).collect(),
241 },
242 )
243}
244
245#[derive(Serialize)]
248struct UninstallResult {
249 runtime: &'static str,
250 container: String,
251 removed: bool,
252 image_removed: bool,
253 purged: bool,
254}
255
256fn uninstall(args: UninstallArgs) -> Result<(), Error> {
257 let runtime = resolve_runtime(args.common.runtime)?;
258 let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
259 let removed = capture(runtime.bin(), &["rm".into(), args.common.name.clone()])
260 .map(|o| o.status.success())
261 .unwrap_or(false);
262
263 let mut image_removed = false;
264 if args.purge {
265 let image = image_tag();
266 image_removed = capture(runtime.bin(), &["rmi".into(), image])
267 .map(|o| o.status.success())
268 .unwrap_or(false);
269 if let Ok(ctx) = cache_context_dir() {
270 let _ = std::fs::remove_dir_all(&ctx);
271 }
272 }
273
274 output::emit(
275 "container_uninstall",
276 &UninstallResult {
277 runtime: runtime.label(),
278 container: args.common.name,
279 removed,
280 image_removed,
281 purged: args.purge,
282 },
283 )
284}
285
286#[derive(Serialize)]
289struct StatusResult {
290 runtime: &'static str,
291 container: String,
292 running: bool,
293 endpoint: String,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 token: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 client_command: Option<String>,
298}
299
300async fn status(args: StatusArgs) -> Result<(), Error> {
301 let runtime = resolve_runtime(args.common.runtime)?;
302 let running = container_running(runtime, &args.common.name);
303 let endpoint = endpoint_url(args.port);
304
305 let token = if running {
306 read_token(runtime, &args.common.name).await.ok()
307 } else {
308 None
309 };
310 let client_command = token.as_deref().map(|t| client_command(args.port, t));
311
312 output::emit(
313 "container_status",
314 &StatusResult {
315 runtime: runtime.label(),
316 container: args.common.name,
317 running,
318 endpoint,
319 token,
320 client_command,
321 },
322 )
323}
324
325fn logs(args: LogsArgs) -> Result<(), Error> {
328 let runtime = resolve_runtime(args.common.runtime)?;
329 let mut argv: Vec<String> = vec!["logs".into()];
330 if args.follow {
331 argv.push("-f".into());
332 }
333 argv.push(args.common.name);
334 exec_inherit(runtime.bin(), &argv)
335}
336
337fn resolve_runtime(explicit: Option<Runtime>) -> Result<Runtime, Error> {
340 if let Some(r) = explicit {
341 return Ok(r);
342 }
343 if let Some(v) = std::env::var_os("AFHTTP_CONTAINER_RUNTIME") {
344 return runtime_from_str(v.to_string_lossy().trim());
345 }
346 if on_path("docker") {
347 Ok(Runtime::Docker)
348 } else if on_path("podman") {
349 Ok(Runtime::Podman)
350 } else if on_path("container") {
351 Ok(Runtime::Apple)
352 } else {
353 Err(Error::new(
354 ErrorCode::InvalidArgument,
355 "no container runtime found: install Docker, Podman, or Apple `container`, or pass --runtime",
356 ))
357 }
358}
359
360fn runtime_from_str(value: &str) -> Result<Runtime, Error> {
361 match value {
362 "docker" => Ok(Runtime::Docker),
363 "podman" => Ok(Runtime::Podman),
364 "apple" | "container" => Ok(Runtime::Apple),
365 other => Err(Error::new(
366 ErrorCode::InvalidArgument,
367 format!("invalid container runtime '{other}': expected docker, podman, or apple"),
368 )),
369 }
370}
371
372fn on_path(bin: &str) -> bool {
373 let Some(paths) = std::env::var_os("PATH") else {
374 return false;
375 };
376 std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file())
377}
378
379fn start_daemon(runtime: Runtime) {
382 if runtime == Runtime::Apple {
383 let _ = capture(runtime.bin(), &["system".into(), "start".into()]);
384 }
385}
386
387fn image_tag() -> String {
390 format!("{IMAGE_REPO}:{VERSION}")
391}
392
393fn volume_name(name: &str) -> String {
394 format!("{name}-data")
395}
396
397fn endpoint_url(port: u16) -> String {
398 format!("ws://127.0.0.1:{port}")
399}
400
401fn client_command(port: u16, token: &str) -> String {
402 format!(
403 "afhttp fetch https://example.com --endpoint-url ws://127.0.0.1:{port} --token-secret {token}"
404 )
405}
406
407fn target_triple(runtime: Runtime, host_arch: &str) -> &'static str {
410 match runtime {
411 Runtime::Apple => "aarch64-unknown-linux-gnu",
412 Runtime::Docker | Runtime::Podman => match host_arch {
413 "aarch64" | "arm64" => "aarch64-unknown-linux-gnu",
414 _ => "x86_64-unknown-linux-gnu",
415 },
416 }
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
421struct Backend {
422 name: &'static str,
423 build_arg: &'static str,
424}
425
426const BACKENDS: [Backend; 5] = [
427 Backend {
428 name: "chrome-headless-shell",
429 build_arg: "WITH_CHROME_HEADLESS_SHELL",
430 },
431 Backend {
432 name: "lightpanda",
433 build_arg: "WITH_LIGHTPANDA",
434 },
435 Backend {
436 name: "fingerprint-chromium",
437 build_arg: "WITH_FINGERPRINT_CHROMIUM",
438 },
439 Backend {
440 name: "camoufox",
441 build_arg: "WITH_CAMOUFOX",
442 },
443 Backend {
444 name: "kasmvnc",
445 build_arg: "WITH_KASMVNC",
446 },
447];
448
449fn resolve_backends(names: &[String]) -> Result<Vec<Backend>, Error> {
450 let mut out = Vec::with_capacity(names.len());
451 for name in names {
452 let backend = BACKENDS.iter().find(|b| b.name == name).ok_or_else(|| {
453 Error::new(
454 ErrorCode::InvalidArgument,
455 format!(
456 "unknown backend '{name}': expected one of {}",
457 BACKENDS
458 .iter()
459 .map(|b| b.name)
460 .collect::<Vec<_>>()
461 .join(", ")
462 ),
463 )
464 })?;
465 if !out.contains(backend) {
466 out.push(*backend);
467 }
468 }
469 Ok(out)
470}
471
472fn validate_install_args(args: &InstallArgs, backends: &[Backend]) -> Result<(), Error> {
473 let camoufox_built = backends.iter().any(|b| b.name == "camoufox");
474 if args.profile != "-" && camoufox_built && host_args_select_camoufox(&args.host_args) {
475 return Err(Error::new(
476 ErrorCode::InvalidArgument,
477 "camoufox does not yet support persistent profiles in afhttp; `container install` defaults to `--profile work`. Use `afhttp container install --profile - --with camoufox -- --browser camoufox`.",
478 ));
479 }
480 Ok(())
481}
482
483fn host_args_select_camoufox(host_args: &[String]) -> bool {
484 host_args
485 .windows(2)
486 .any(|pair| pair[0] == "--browser" && pair[1].as_str() == "camoufox")
487 || host_args.iter().any(|arg| arg == "--browser=camoufox")
488}
489
490fn validate_container_image_host_args(
491 runtime: Runtime,
492 image: &str,
493 host_args: &[String],
494) -> Result<(), Error> {
495 if !host_args_need_display_takeover_support(host_args) {
496 return Ok(());
497 }
498 let Some(help) = container_image_host_help(runtime, image) else {
499 return Ok(());
500 };
501 if help.contains("--display-provider") {
502 return Ok(());
503 }
504 Err(Error::new(
505 ErrorCode::InvalidArgument,
506 format!(
507 "container image `{image}` contains an older afhttp host binary that does not support `--takeover display --display-provider kasmvnc`; rebuild the image from this source checkout: `afhttp container install --from-source --with kasmvnc -- --takeover display --display-provider kasmvnc`"
508 ),
509 ))
510}
511
512fn host_args_need_display_takeover_support(host_args: &[String]) -> bool {
513 host_args.iter().any(|arg| arg == "--display-provider")
514 || host_args
515 .iter()
516 .any(|arg| arg.starts_with("--display-provider="))
517 || host_args
518 .windows(2)
519 .any(|pair| pair[0] == "--takeover" && pair[1].as_str() == "display")
520 || host_args.iter().any(|arg| arg == "--takeover=display")
521}
522
523fn container_image_host_help(runtime: Runtime, image: &str) -> Option<String> {
524 let argv = image_host_help_args(image);
525 let out = capture(runtime.bin(), &argv).ok()?;
526 if !out.status.success() {
527 return None;
528 }
529 let mut help = String::new();
530 help.push_str(&String::from_utf8_lossy(&out.stdout));
531 help.push_str(&String::from_utf8_lossy(&out.stderr));
532 Some(help)
533}
534
535fn image_host_help_args(image: &str) -> Vec<String> {
536 vec![
537 "run".into(),
538 "--rm".into(),
539 "--entrypoint".into(),
540 "/usr/local/bin/afhttp".into(),
541 image.to_string(),
542 "host".into(),
543 "--help".into(),
544 ]
545}
546
547enum BuildSource<'a> {
552 Embedded { ctx: &'a Path, target: &'a str },
553 FromSource { ctx: &'a Path },
554}
555
556fn build_args(
557 image: &str,
558 runtime: Runtime,
559 source: BuildSource,
560 backends: &[Backend],
561) -> Vec<String> {
562 let mut a: Vec<String> = vec!["build".into()];
563 if runtime == Runtime::Apple {
564 a.push("--platform".into());
565 a.push("linux/arm64".into());
566 }
567 let ctx = match source {
568 BuildSource::Embedded { ctx, target } => {
569 a.push("--build-arg".into());
570 a.push("AFHTTP_BIN_FROM=downloader".into());
571 a.push("--build-arg".into());
572 a.push(format!("AFHTTP_VERSION={VERSION}"));
573 a.push("--build-arg".into());
574 a.push(format!("AFHTTP_TARGET={target}"));
575 ctx
576 }
577 BuildSource::FromSource { ctx } => {
578 a.push("--build-arg".into());
579 a.push("AFHTTP_BIN_FROM=builder".into());
580 ctx
581 }
582 };
583 for b in backends {
584 a.push("--build-arg".into());
585 a.push(format!("{}=1", b.build_arg));
586 }
587 a.push("-t".into());
588 a.push(image.to_string());
589 a.push("-f".into());
590 a.push(
591 ctx.join("container/docker/Dockerfile")
592 .to_string_lossy()
593 .into_owned(),
594 );
595 a.push(ctx.to_string_lossy().into_owned());
596 a
597}
598
599fn resolve_source_context(arg: Option<&str>) -> Result<PathBuf, Error> {
601 if let Some(p) = arg {
602 return validate_source_context(PathBuf::from(p), "--context");
603 }
604 let cwd = std::env::current_dir()
605 .map_err(|e| Error::new(ErrorCode::IoError, format!("cannot read current dir: {e}")))?;
606 if is_source_context(&cwd) {
607 return Ok(cwd);
608 }
609 let manifest_dir = PathBuf::from(MANIFEST_DIR);
610 if manifest_dir != cwd && is_source_context(&manifest_dir) {
611 return Ok(manifest_dir);
612 }
613 Err(Error::new(
614 ErrorCode::InvalidArgument,
615 format!(
616 "--from-source needs a source checkout: checked {} and {} \
617 (run from the spore root or pass --context <dir>)",
618 cwd.display(),
619 manifest_dir.display()
620 ),
621 ))
622}
623
624fn validate_source_context(dir: PathBuf, source: &str) -> Result<PathBuf, Error> {
625 let dockerfile = dir.join("container/docker/Dockerfile");
626 if !dockerfile.is_file() {
627 return Err(Error::new(
628 ErrorCode::InvalidArgument,
629 format!(
630 "--from-source {source} needs a source checkout: {} not found \
631 (run from the spore root or pass --context <dir>)",
632 dockerfile.display()
633 ),
634 ));
635 }
636 Ok(dir)
637}
638
639fn is_source_context(dir: &Path) -> bool {
640 dir.join("container/docker/Dockerfile").is_file()
641}
642
643fn run_args(
644 name: &str,
645 image: &str,
646 port: u16,
647 profile: &str,
648 shm_size: &str,
649 host_args: &[String],
650) -> Vec<String> {
651 let mut a: Vec<String> = vec![
652 "run".into(),
653 "-d".into(),
654 "--name".into(),
655 name.to_string(),
656 "-v".into(),
657 format!("{}:/data", volume_name(name)),
658 "-e".into(),
659 format!("AFHTTP_PORT={port}"),
660 "-e".into(),
661 format!("AFHTTP_PROFILE={profile}"),
662 "--shm-size".into(),
663 shm_size.to_string(),
664 "-p".into(),
665 format!("127.0.0.1:{port}:{port}"),
666 image.to_string(),
667 ];
668 a.extend(host_args.iter().cloned());
669 a
670}
671
672fn spawn_error(bin: &str, err: &std::io::Error) -> Error {
675 if err.kind() == std::io::ErrorKind::NotFound {
676 Error::new(
677 ErrorCode::InvalidArgument,
678 format!("container runtime `{bin}` not found on PATH"),
679 )
680 } else {
681 Error::new(
682 ErrorCode::IoError,
683 format!("spawning `{bin}` failed: {err}"),
684 )
685 }
686}
687
688fn exec_inherit(bin: &str, args: &[String]) -> Result<(), Error> {
690 let status = Command::new(bin)
691 .args(args)
692 .status()
693 .map_err(|e| spawn_error(bin, &e))?;
694 if status.success() {
695 Ok(())
696 } else {
697 Err(Error::new(
698 ErrorCode::InternalError,
699 format!("`{bin} {}` failed ({status})", args.join(" ")),
700 ))
701 }
702}
703
704fn capture(bin: &str, args: &[String]) -> Result<std::process::Output, Error> {
706 Command::new(bin)
707 .args(args)
708 .output()
709 .map_err(|e| spawn_error(bin, &e))
710}
711
712fn image_exists(runtime: Runtime, image: &str) -> bool {
713 capture(
714 runtime.bin(),
715 &["image".into(), "inspect".into(), image.to_string()],
716 )
717 .map(|o| o.status.success())
718 .unwrap_or(false)
719}
720
721fn container_running(runtime: Runtime, name: &str) -> bool {
722 capture(runtime.bin(), &["ps".into()])
724 .map(|o| String::from_utf8_lossy(&o.stdout).contains(name))
725 .unwrap_or(false)
726}
727
728async fn read_token(runtime: Runtime, name: &str) -> Result<String, Error> {
731 let argv = vec![
732 "exec".into(),
733 name.to_string(),
734 "cat".into(),
735 "/data/afhttp/host-token".into(),
736 ];
737 for attempt in 0..20 {
738 if let Ok(out) = capture(runtime.bin(), &argv) {
739 if out.status.success() {
740 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
741 if !token.is_empty() {
742 return Ok(token);
743 }
744 }
745 }
746 if !container_running(runtime, name) {
747 return Err(container_launch_failure_error(
748 runtime,
749 name,
750 "container exited before the host token could be read",
751 ));
752 }
753 if attempt < 19 {
754 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
755 }
756 }
757 Err(container_launch_failure_error(
758 runtime,
759 name,
760 "host token was not available before the startup deadline",
761 ))
762}
763
764async fn wait_for_container_health(
765 runtime: Runtime,
766 name: &str,
767 port: u16,
768 token: &str,
769) -> Result<(), Error> {
770 let endpoint = endpoint_url(port);
771 let client = crate::sdk::Client::connect(&endpoint)?.with_token(token.to_string());
772 for attempt in 0..30 {
773 if !container_running(runtime, name) {
774 return Err(container_launch_failure_error(
775 runtime,
776 name,
777 "container exited before /health became ready",
778 ));
779 }
780 match client.health().await {
781 Ok(health) if health.status == "ok" => return Ok(()),
782 Ok(health) => {
783 if let Some(backend_error) = health.backend_error {
784 return Err(Error::new(
785 backend_error.error_code,
786 format!(
787 "container host /health reported {}: {}",
788 health.status, backend_error.error
789 ),
790 ));
791 }
792 }
793 Err(_) => {}
794 }
795 if attempt < 29 {
796 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
797 }
798 }
799 Err(container_launch_failure_error(
800 runtime,
801 name,
802 "container host did not pass /health before the startup deadline",
803 ))
804}
805
806fn container_launch_failure_error(runtime: Runtime, name: &str, reason: &str) -> Error {
807 let logs = container_logs_summary(runtime, name);
808 let lower = logs.to_ascii_lowercase();
809 let code = if lower.contains("backend_unsupported")
810 || lower.contains("persistent profiles")
811 || lower.contains("does not yet support")
812 {
813 ErrorCode::BackendUnsupported
814 } else {
815 ErrorCode::BrowserLaunchFailed
816 };
817 let mut detail = format!("container host launch failed: {reason}");
818 if !logs.is_empty() {
819 detail.push_str("; recent logs: ");
820 detail.push_str(&logs);
821 }
822 Error::new(code, detail)
823}
824
825fn container_logs_summary(runtime: Runtime, name: &str) -> String {
826 let Ok(out) = capture(runtime.bin(), &["logs".into(), name.to_string()]) else {
827 return String::new();
828 };
829 let mut combined = String::new();
830 combined.push_str(&String::from_utf8_lossy(&out.stdout));
831 combined.push_str(&String::from_utf8_lossy(&out.stderr));
832 let lines: Vec<&str> = combined.lines().rev().take(60).collect();
833 let mut summary = lines.into_iter().rev().collect::<Vec<_>>().join(" | ");
834 const MAX: usize = 4000;
835 if summary.len() > MAX {
836 let start = summary.len() - MAX;
837 summary = format!("...{}", &summary[start..]);
838 }
839 summary
840}
841
842fn build_failed_error(target: &str) -> Error {
843 Error::new(
844 ErrorCode::InternalError,
845 format!(
846 "image build failed. If v{VERSION} has no published release asset for \
847 {target}, build from a source checkout instead: \
848 `afhttp container install --from-source` (or \
849 docker compose -f container/docker/compose.yaml up --build)"
850 ),
851 )
852}
853
854fn cache_context_dir() -> Result<PathBuf, Error> {
857 let base = std::env::var_os("XDG_CACHE_HOME")
858 .map(PathBuf::from)
859 .filter(|p| p.is_absolute())
860 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
861 .ok_or_else(|| {
862 Error::new(
863 ErrorCode::IoError,
864 "cannot resolve cache dir: set HOME or XDG_CACHE_HOME",
865 )
866 })?;
867 Ok(base.join("afhttp").join("container").join(VERSION))
868}
869
870fn write_build_context() -> Result<PathBuf, Error> {
871 let root = cache_context_dir()?;
872 let dir = root.join("container").join("docker");
876 std::fs::create_dir_all(&dir)?;
877 std::fs::write(dir.join("Dockerfile"), DOCKERFILE)?;
878 std::fs::write(dir.join("install-backends.sh"), INSTALL_BACKENDS)?;
879 std::fs::write(dir.join("entrypoint.sh"), ENTRYPOINT)?;
880 Ok(root)
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn runtime_from_str_parses_and_rejects() {
889 assert_eq!(runtime_from_str("docker").unwrap(), Runtime::Docker);
890 assert_eq!(runtime_from_str("podman").unwrap(), Runtime::Podman);
891 assert_eq!(runtime_from_str("apple").unwrap(), Runtime::Apple);
892 assert_eq!(runtime_from_str("container").unwrap(), Runtime::Apple);
893 assert_eq!(
894 runtime_from_str("nerdctl").unwrap_err().error_code,
895 ErrorCode::InvalidArgument
896 );
897 }
898
899 #[test]
900 fn explicit_runtime_wins_over_detection() {
901 assert_eq!(
902 resolve_runtime(Some(Runtime::Apple)).unwrap(),
903 Runtime::Apple
904 );
905 assert_eq!(
906 resolve_runtime(Some(Runtime::Docker)).unwrap(),
907 Runtime::Docker
908 );
909 }
910
911 #[test]
912 fn target_triple_tracks_runtime_and_arch() {
913 assert_eq!(
914 target_triple(Runtime::Apple, "x86_64"),
915 "aarch64-unknown-linux-gnu"
916 );
917 assert_eq!(
918 target_triple(Runtime::Docker, "aarch64"),
919 "aarch64-unknown-linux-gnu"
920 );
921 assert_eq!(
922 target_triple(Runtime::Docker, "x86_64"),
923 "x86_64-unknown-linux-gnu"
924 );
925 assert_eq!(
927 target_triple(Runtime::Podman, "aarch64"),
928 "aarch64-unknown-linux-gnu"
929 );
930 assert_eq!(
931 target_triple(Runtime::Podman, "x86_64"),
932 "x86_64-unknown-linux-gnu"
933 );
934 }
935
936 #[test]
937 fn backend_names_map_to_build_args_and_reject_unknown() {
938 let resolved = resolve_backends(&["camoufox".into(), "kasmvnc".into()]).unwrap();
939 assert_eq!(resolved.len(), 2);
940 assert_eq!(resolved[0].build_arg, "WITH_CAMOUFOX");
941 assert_eq!(resolved[1].build_arg, "WITH_KASMVNC");
942
943 let deduped = resolve_backends(&["camoufox".into(), "camoufox".into()]).unwrap();
945 assert_eq!(deduped.len(), 1);
946
947 assert_eq!(
948 resolve_backends(&["nope".into()]).unwrap_err().error_code,
949 ErrorCode::InvalidArgument
950 );
951 }
952
953 #[test]
954 fn install_precheck_rejects_camoufox_with_persistent_profile() {
955 let args = InstallArgs {
956 common: CommonArgs {
957 runtime: Some(Runtime::Docker),
958 name: "afhttp-host".into(),
959 },
960 port: 9222,
961 profile: "work".into(),
962 shm_size: "1g".into(),
963 with: vec!["camoufox".into()],
964 rebuild: false,
965 from_source: false,
966 context: None,
967 host_args: vec!["--browser".into(), "camoufox".into()],
968 };
969 let backends = resolve_backends(&args.with).unwrap();
970 let err = validate_install_args(&args, &backends).unwrap_err();
971 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
972 assert!(err.detail.contains("--profile -"));
973 }
974
975 #[test]
976 fn install_precheck_allows_camoufox_ephemeral_profile() {
977 let args = InstallArgs {
978 common: CommonArgs {
979 runtime: Some(Runtime::Docker),
980 name: "afhttp-host".into(),
981 },
982 port: 9222,
983 profile: "-".into(),
984 shm_size: "1g".into(),
985 with: vec!["camoufox".into()],
986 rebuild: false,
987 from_source: false,
988 context: None,
989 host_args: vec!["--browser=camoufox".into()],
990 };
991 let backends = resolve_backends(&args.with).unwrap();
992 validate_install_args(&args, &backends).unwrap();
993 }
994
995 #[test]
996 fn display_host_args_trigger_image_support_probe() {
997 assert!(host_args_need_display_takeover_support(&[
998 "--takeover".into(),
999 "display".into()
1000 ]));
1001 assert!(host_args_need_display_takeover_support(&[
1002 "--takeover=display".into()
1003 ]));
1004 assert!(host_args_need_display_takeover_support(&[
1005 "--display-provider".into(),
1006 "kasmvnc".into()
1007 ]));
1008 assert!(host_args_need_display_takeover_support(&[
1009 "--display-provider=kasmvnc".into()
1010 ]));
1011 assert!(!host_args_need_display_takeover_support(&[
1012 "--takeover".into(),
1013 "screencast".into()
1014 ]));
1015 }
1016
1017 #[test]
1018 fn image_host_help_args_bypasses_entrypoint() {
1019 let args = image_host_help_args("afhttp-host:dev");
1020 assert_eq!(args[0], "run");
1021 assert!(args.contains(&"--rm".to_string()));
1022 assert!(args.contains(&"--entrypoint".to_string()));
1023 assert!(args.contains(&"/usr/local/bin/afhttp".to_string()));
1024 assert_eq!(args[args.len() - 3], "afhttp-host:dev");
1025 assert_eq!(args[args.len() - 2], "host");
1026 assert_eq!(args[args.len() - 1], "--help");
1027 }
1028
1029 #[test]
1030 fn embedded_build_args_include_version_target_and_apple_platform() {
1031 let ctx = PathBuf::from("/cache/ctx");
1032 let backends = resolve_backends(&["lightpanda".into()]).unwrap();
1033 let docker = build_args(
1034 "afhttp-host:1.2.3",
1035 Runtime::Docker,
1036 BuildSource::Embedded {
1037 ctx: &ctx,
1038 target: "x86_64-unknown-linux-gnu",
1039 },
1040 &backends,
1041 );
1042 assert_eq!(docker[0], "build");
1043 assert!(!docker.contains(&"--platform".to_string()));
1044 assert!(docker.contains(&"AFHTTP_BIN_FROM=downloader".to_string()));
1045 assert!(docker.contains(&format!("AFHTTP_VERSION={VERSION}")));
1046 assert!(docker.contains(&"AFHTTP_TARGET=x86_64-unknown-linux-gnu".to_string()));
1047 assert!(docker.contains(&"WITH_LIGHTPANDA=1".to_string()));
1048 assert_eq!(
1049 docker[docker.len() - 2],
1050 "/cache/ctx/container/docker/Dockerfile"
1051 );
1052 assert_eq!(docker.last().unwrap(), "/cache/ctx");
1053
1054 let apple = build_args(
1055 "afhttp-host:1.2.3",
1056 Runtime::Apple,
1057 BuildSource::Embedded {
1058 ctx: &ctx,
1059 target: "aarch64-unknown-linux-gnu",
1060 },
1061 &[],
1062 );
1063 let pos = apple.iter().position(|a| a == "--platform").unwrap();
1064 assert_eq!(apple[pos + 1], "linux/arm64");
1065 }
1066
1067 #[test]
1068 fn from_source_build_args_use_canonical_dockerfile_no_release_args() {
1069 let repo = PathBuf::from("/repo");
1070 let backends = resolve_backends(&["camoufox".into()]).unwrap();
1071 let args = build_args(
1072 "afhttp-host:1.2.3",
1073 Runtime::Podman,
1074 BuildSource::FromSource { ctx: &repo },
1075 &backends,
1076 );
1077 assert!(args.contains(&"AFHTTP_BIN_FROM=builder".to_string()));
1079 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_VERSION=")));
1080 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_TARGET=")));
1081 assert!(args.contains(&"WITH_CAMOUFOX=1".to_string()));
1082 assert_eq!(args[args.len() - 2], "/repo/container/docker/Dockerfile");
1083 assert_eq!(args.last().unwrap(), "/repo");
1084 assert!(!args.contains(&"--platform".to_string()));
1086 }
1087
1088 #[test]
1089 fn run_args_publish_loopback_and_pass_host_args() {
1090 let a = run_args(
1091 "afhttp-host",
1092 "afhttp-host:1.2.3",
1093 9222,
1094 "work",
1095 "1g",
1096 &["--browser".into(), "camoufox".into()],
1097 );
1098 assert!(a.contains(&"afhttp-host-data:/data".to_string()));
1099 assert!(a.contains(&"AFHTTP_PORT=9222".to_string()));
1100 assert!(a.contains(&"AFHTTP_PROFILE=work".to_string()));
1101 assert!(a.contains(&"127.0.0.1:9222:9222".to_string()));
1102 let img = a.iter().position(|x| x == "afhttp-host:1.2.3").unwrap();
1104 let br = a.iter().position(|x| x == "--browser").unwrap();
1105 assert!(img < br);
1106 }
1107
1108 #[test]
1109 fn client_command_uses_loopback_endpoint() {
1110 let cmd = client_command(9333, "deadbeef");
1111 assert!(cmd.contains("--endpoint-url ws://127.0.0.1:9333"));
1112 assert!(cmd.contains("--token-secret deadbeef"));
1113 }
1114
1115 #[test]
1116 fn build_failure_error_points_at_compose_fallback() {
1117 let err = build_failed_error("aarch64-unknown-linux-gnu");
1118 assert_eq!(err.error_code, ErrorCode::InternalError);
1119 assert!(err.detail.contains("compose"));
1120 assert!(err.detail.contains("aarch64-unknown-linux-gnu"));
1121 }
1122}