Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions crates/fctl-host/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ impl Daemon {

/// Pulls the host's sandbox image in the background when it isn't here
/// yet, so that the first session after an upgrade doesn't wait for it.
/// When it can't be pulled, as before a release's image is published,
/// it pulls the image sessions start from instead.
fn prefetch_image(&self) {
if !self.ctx.driver.uses_images() {
return;
Expand All @@ -125,9 +127,19 @@ impl Daemon {
if ctx.driver.has_image(&image).await {
return;
}
// Never over a fallback that is here: sessions start from it.
let fallback = match ctx.cfg.fallback_image() {
Some(f) if !ctx.driver.has_image(&f).await => Some(f),
_ => None,
};
tracing::info!("pulling {image}");
match ctx.driver.pull(&image, false).await {
Ok(()) => tracing::info!("pulled {image}"),
let log = |line: String| tracing::info!("{line}");
match ctx
.driver
.pull_or_fallback(&image, fallback.as_deref(), false, &log)
.await
{
Ok(pulled) => tracing::info!("pulled {pulled}"),
Err(e) => tracing::warn!("pulling {image}: {e:#} (sessions try again)"),
}
});
Expand Down
121 changes: 121 additions & 0 deletions crates/fctl-host/src/driver/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,37 @@ impl ContainerDriver {
Ok(())
}

/// Pulls `image`, or `fallback` when `image` can't be pulled and isn't
/// here either: what sandboxes start from then. Returns the image it
/// pulled, and says in `log` when it falls back.
pub async fn pull_or_fallback(
&self,
image: &str,
fallback: Option<&str>,
progress: bool,
log: &(dyn Fn(String) + Send + Sync),
) -> Result<String> {
let error = match self.pull(image, progress).await {
Ok(()) => return Ok(image.to_owned()),
Err(e) => e,
};
let Some(fallback) = fallback else {
return Err(error);
};
if self.has_image(image).await {
return Err(error);
}
log(format!(
"{image} can't be pulled; pulling {fallback} instead"
));
match self.pull(fallback, progress).await {
Ok(()) => Ok(fallback.to_owned()),
Err(_) => Err(error.context(format!(
"{image} can't be pulled, and neither can {fallback} (`fctl host image build` builds it here)"
))),
}
}

pub async fn install_agent(&self, sb: &Sandbox, binary: &Path) -> Result<()> {
let src = binary.display().to_string();
let dest = format!("{}:{AGENT_PATH}", sb.name);
Expand Down Expand Up @@ -650,6 +681,96 @@ esac
Ok(())
}

#[tokio::test]
async fn pulling_the_default_image_falls_back_to_the_latest_published_one() -> Result<()> {
let lines = Mutex::new(Vec::new());
let log = |line: String| {
lines
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(line)
};
let logged = || lines.lock().unwrap_or_else(PoisonError::into_inner).clone();
let fallback = Some(LATEST_IMAGE);

let cli = FakeCli::new(&[], &[VERSION_IMAGE, LATEST_IMAGE])?;
let docker = cli.driver(Flavor::Docker);
let pulled = docker
.pull_or_fallback(VERSION_IMAGE, fallback, false, &log)
.await?;
assert_eq!(pulled, VERSION_IMAGE);
assert_eq!(cli.calls(), [format!("pull {VERSION_IMAGE}")]);
assert!(logged().is_empty());

// A release's image is published within the hour after the release.
let cli = FakeCli::new(&[], &[LATEST_IMAGE])?;
let docker = cli.driver(Flavor::Docker);
let pulled = docker
.pull_or_fallback(VERSION_IMAGE, fallback, false, &log)
.await?;
assert_eq!(pulled, LATEST_IMAGE);
assert!(
cli.calls().contains(&format!("pull {LATEST_IMAGE}")),
"{:?}",
cli.calls()
);
let note = format!("{VERSION_IMAGE} can't be pulled; pulling {LATEST_IMAGE} instead");
assert_eq!(logged(), [note]);

// Sessions start from an image that is here, a build of it say.
let cli = FakeCli::new(&[VERSION_IMAGE], &[LATEST_IMAGE])?;
let docker = cli.driver(Flavor::Docker);
let error = error_of(
docker
.pull_or_fallback(VERSION_IMAGE, fallback, false, &log)
.await,
);
assert!(
error.ends_with(&format!("{VERSION_IMAGE}: manifest unknown")),
"{error}"
);
assert!(!cli.calls().contains(&format!("pull {LATEST_IMAGE}")));

let cli = FakeCli::new(&[], &[])?;
let docker = cli.driver(Flavor::Docker);
let error = error_of(
docker
.pull_or_fallback(VERSION_IMAGE, fallback, false, &log)
.await,
);
assert!(
error.starts_with(&format!(
"{VERSION_IMAGE} can't be pulled, and neither can {LATEST_IMAGE} (`fctl host image build` builds it here): "
)),
"{error}"
);
assert!(
error.ends_with(&format!("{VERSION_IMAGE}: manifest unknown")),
"{error}"
);

// An image set in host.toml has nothing to fall back to.
let error = error_of(
docker
.pull_or_fallback("ghcr.io/acme/agent:1", None, false, &log)
.await,
);
assert!(
error.ends_with("ghcr.io/acme/agent:1: manifest unknown"),
"{error}"
);
assert_eq!(
cli.calls(),
[
format!("pull {VERSION_IMAGE}"),
format!("image inspect --format {{{{.Id}}}} {VERSION_IMAGE}"),
format!("pull {LATEST_IMAGE}"),
"pull ghcr.io/acme/agent:1".to_owned(),
]
);
Ok(())
}

#[tokio::test]
async fn other_failures_are_not_about_the_image() -> Result<()> {
let cli = FakeCli::new(&[VERSION_IMAGE], &[LATEST_IMAGE])?;
Expand Down
15 changes: 15 additions & 0 deletions crates/fctl-host/src/driver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ impl Driver {
}
}

/// Pulls `image`, or `fallback` when `image` can't be pulled and isn't
/// here either; returns the image it pulled.
pub async fn pull_or_fallback(
&self,
image: &str,
fallback: Option<&str>,
progress: bool,
log: &(dyn Fn(String) + Send + Sync),
) -> Result<String> {
match self {
Self::Container(d) => d.pull_or_fallback(image, fallback, progress, log).await,
_ => anyhow::bail!("the {} driver does not use images", self.name()),
}
}

pub async fn probe(&self) -> DriverInfo {
match self {
Self::Process(d) => d.probe().await,
Expand Down
33 changes: 29 additions & 4 deletions crates/fctl/src/cmd/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,30 @@ pub async fn run(server: Option<&str>, cmd: HostCmd, json: bool) -> Result<()> {
);
}
let image = cfg.image();
d.pull(&image, !json).await?;
let note = |line: String| {
if !json {
println!("{line}");
}
};
// The default image falls back to `latest` until its version's is published.
let pulled = d
.pull_or_fallback(&image, cfg.fallback_image().as_deref(), !json, &note)
.await?;
let instead_of = (pulled != image).then_some(&image);
if json {
return style::print_json(&serde_json::json!({ "image": image }));
return style::print_json(
&serde_json::json!({ "image": pulled, "instead_of": instead_of }),
);
}
println!("{} Pulled {pulled}", style::accent("✓"));
if instead_of.is_some() {
println!(
" {}",
style::dim(&format!(
"Sessions start from it until {image} can be pulled: a release's image is published within the hour after the release."
))
);
}
println!("{} Pulled {image}", style::accent("✓"));
Ok(())
}
HostCmd::Image(ImageCmd::Build {
Expand Down Expand Up @@ -375,11 +394,17 @@ async fn doctor() -> Result<()> {
if d.has_image(&image).await {
check(true, &format!("sandbox image {image}"), "");
} else {
let meanwhile = match cfg.fallback_image() {
Some(f) if d.has_image(&f).await => {
format!("; sessions start from {f} until it is")
}
_ => String::new(),
};
println!(
"{} {}",
style::dim("·"),
style::dim(&format!(
"sandbox image {image}: not here yet (the host pulls it when it starts; `fctl host image pull` pulls it now)"
"sandbox image {image}: not here yet{meanwhile} (the host pulls it when it starts; `fctl host image pull` pulls it now)"
))
);
}
Expand Down
8 changes: 5 additions & 3 deletions docs/sandbox-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ version, so upgrading the host (`fctl self update`) moves its new sessions
to the new version's image. The host pulls the image in the background when
it starts, and a session that starts before it has the image pulls it
itself. When the image can't be pulled, as in the hour after a release
before its image is published, the session starts from `latest` instead and
says so in its log.
before its image is published, the host pulls `latest` instead, and
sessions start from it and say so in their log. The first session after the
version's image is published pulls it.

The host pulls a tag once. To get a newer build of it, with a newer Claude
Code, run `fctl host image pull`.
Code, run `fctl host image pull`. While the version's image can't be
pulled, it pulls `latest` instead, and says so.

- **Another image.** `image` in `host.toml` (or `fctl host install --image
…`) sets the image for sessions whose project sets none, and a project's
Expand Down