diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36074836e..a97fd8165 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,27 +115,15 @@ jobs: id: platform run: echo "pair=$(echo ${{ matrix.platform }} | tr '/' '-')" >> "$GITHUB_OUTPUT" - - name: Build and push slim + - name: Build and push uses: docker/build-push-action@v6 with: context: . - target: slim platforms: ${{ matrix.platform }} push: true - tags: ${{ env.IMAGE }}:slim-${{ steps.platform.outputs.pair }} - cache-from: type=gha,scope=slim-${{ steps.platform.outputs.pair }} - cache-to: type=gha,mode=max,scope=slim-${{ steps.platform.outputs.pair }} - - - name: Build and push full - uses: docker/build-push-action@v6 - with: - context: . - target: full - platforms: ${{ matrix.platform }} - push: true - tags: ${{ env.IMAGE }}:full-${{ steps.platform.outputs.pair }} - cache-from: type=gha,scope=full-${{ steps.platform.outputs.pair }} - cache-to: type=gha,mode=max,scope=full-${{ steps.platform.outputs.pair }} + tags: ${{ env.IMAGE }}:build-${{ steps.platform.outputs.pair }} + cache-from: type=gha,scope=build-${{ steps.platform.outputs.pair }} + cache-to: type=gha,mode=max,scope=build-${{ steps.platform.outputs.pair }} merge-docker: needs: build-docker @@ -155,22 +143,13 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Create slim multi-arch manifest - run: | - docker buildx imagetools create \ - --tag ${{ env.IMAGE }}:${{ needs.build-docker.outputs.version }}-slim \ - --tag ${{ env.IMAGE }}:slim \ - ${{ env.IMAGE }}:slim-linux-amd64 \ - ${{ env.IMAGE }}:slim-linux-arm64 - - - name: Create full multi-arch manifest + - name: Create multi-arch manifest run: | docker buildx imagetools create \ - --tag ${{ env.IMAGE }}:${{ needs.build-docker.outputs.version }}-full \ - --tag ${{ env.IMAGE }}:full \ + --tag ${{ env.IMAGE }}:${{ needs.build-docker.outputs.version }} \ --tag ${{ env.IMAGE }}:latest \ - ${{ env.IMAGE }}:full-linux-amd64 \ - ${{ env.IMAGE }}:full-linux-arm64 + ${{ env.IMAGE }}:build-linux-amd64 \ + ${{ env.IMAGE }}:build-linux-arm64 - name: Log in to Fly registry if: github.repository_owner == 'spacedriveapp' diff --git a/Cargo.lock b/Cargo.lock index 4801c6357..20d3e0998 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1179,6 +1179,7 @@ dependencies = [ "base64 0.22.1", "cfg-if", "chromiumoxide_cdp", + "chromiumoxide_fetcher", "chromiumoxide_types", "dunce", "fnv", @@ -1208,6 +1209,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "chromiumoxide_fetcher" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e39b54dfcb6973284f55cf3639d44e84d23feed4e2e7d1faa4a9029a365737" +dependencies = [ + "anyhow", + "directories", + "reqwest 0.12.28", + "serde", + "thiserror 1.0.69", + "tokio", + "tracing", + "windows-version", + "zip 0.6.6", +] + [[package]] name = "chromiumoxide_pdl" version = "0.8.0" @@ -2611,6 +2629,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -8506,7 +8533,7 @@ dependencies = [ "twitch-irc", "urlencoding", "uuid", - "zip", + "zip 2.4.2", ] [[package]] @@ -10686,6 +10713,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -11187,6 +11223,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 9c43c1f5c..5f26c253e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,7 +132,7 @@ arrow-array = "57.3.0" arrow-schema = "57.3.0" # Browser automation -chromiumoxide = { version = "0.8", features = ["tokio-runtime"], default-features = false } +chromiumoxide = { version = "0.8", features = ["tokio-runtime", "_fetcher-rustls-tokio"], default-features = false } chromiumoxide_cdp = "0.8" # Templating for prompts diff --git a/Dockerfile b/Dockerfile index 438462390..18ba2e5d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,9 +43,10 @@ RUN SPACEBOT_SKIP_FRONTEND_BUILD=1 cargo build --release \ && mv /build/target/release/spacebot /usr/local/bin/spacebot \ && cargo clean -p spacebot --release --target-dir /build/target -# ---- Slim stage ---- -# Minimal runtime with just the binary. No browser. -FROM debian:bookworm-slim AS slim +# ---- Runtime stage ---- +# Minimal runtime with Chrome runtime libraries for fetcher-downloaded Chromium. +# Chrome itself is downloaded on first browser tool use and cached on the volume. +FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ @@ -53,28 +54,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gh \ bubblewrap \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /usr/local/bin/spacebot /usr/local/bin/spacebot -COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -ENV SPACEBOT_DIR=/data -ENV SPACEBOT_DEPLOYMENT=docker -EXPOSE 19898 18789 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD curl -f http://localhost:19898/api/health || exit 1 - -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["spacebot", "start", "--foreground"] - -# ---- Full stage ---- -# Slim + Chromium for browser workers. -FROM slim AS full - -RUN apt-get update && apt-get install -y --no-install-recommends \ - chromium \ + openssh-server \ + # Chrome runtime dependencies — required whether Chrome is system-installed + # or downloaded by the built-in fetcher. The fetcher provides the browser + # binary; these are the shared libraries it links against. fonts-liberation \ libnss3 \ libatk-bridge2.0-0 \ @@ -91,5 +74,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxtst6 \ && rm -rf /var/lib/apt/lists/* -ENV CHROME_PATH=/usr/bin/chromium -ENV CHROME_FLAGS="--no-sandbox --disable-dev-shm-usage --disable-gpu" +COPY --from=builder /usr/local/bin/spacebot /usr/local/bin/spacebot +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +ENV SPACEBOT_DIR=/data +ENV SPACEBOT_DEPLOYMENT=docker +EXPOSE 19898 18789 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:19898/api/health || exit 1 + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["spacebot", "start", "--foreground"] diff --git a/fly.staging.toml b/fly.staging.toml index 97da79dce..d0af60450 100644 --- a/fly.staging.toml +++ b/fly.staging.toml @@ -3,7 +3,6 @@ primary_region = "iad" [build] dockerfile = "Dockerfile" - target = "full" [env] SPACEBOT_DIR = "/data" diff --git a/fly.toml b/fly.toml index 7e5fee5c6..fd64ec6f9 100644 --- a/fly.toml +++ b/fly.toml @@ -3,7 +3,6 @@ primary_region = "iad" [build] dockerfile = "Dockerfile" - target = "full" [env] SPACEBOT_DIR = "/data" diff --git a/src/config.rs b/src/config.rs index 58f0c5458..9d19bb851 100644 --- a/src/config.rs +++ b/src/config.rs @@ -870,6 +870,9 @@ pub struct BrowserConfig { pub executable_path: Option, /// Directory for storing screenshots and other browser artifacts. pub screenshot_dir: Option, + /// Directory for caching a fetcher-downloaded Chromium binary. + /// Populated from `{instance_dir}/chrome_cache` during config resolution. + pub chrome_cache_dir: PathBuf, } impl Default for BrowserConfig { @@ -880,6 +883,7 @@ impl Default for BrowserConfig { evaluate_enabled: false, executable_path: None, screenshot_dir: None, + chrome_cache_dir: PathBuf::from("chrome_cache"), } } } @@ -4121,10 +4125,13 @@ impl Config { let mut api = ApiConfig::default(); api.bind = hosted_api_bind(api.bind); + let mut defaults = DefaultsConfig::default(); + defaults.browser.chrome_cache_dir = instance_dir.join("chrome_cache"); + Ok(Self { instance_dir: instance_dir.to_path_buf(), llm, - defaults: DefaultsConfig::default(), + defaults, agents, links: Vec::new(), groups: Vec::new(), @@ -4729,23 +4736,31 @@ impl Config { .unwrap_or(base_defaults.warmup.startup_delay_secs), }) .unwrap_or(base_defaults.warmup), - browser: toml - .defaults - .browser - .map(|b| { - let base = &base_defaults.browser; - BrowserConfig { - enabled: b.enabled.unwrap_or(base.enabled), - headless: b.headless.unwrap_or(base.headless), - evaluate_enabled: b.evaluate_enabled.unwrap_or(base.evaluate_enabled), - executable_path: b.executable_path.or_else(|| base.executable_path.clone()), - screenshot_dir: b - .screenshot_dir - .map(PathBuf::from) - .or_else(|| base.screenshot_dir.clone()), - } - }) - .unwrap_or_else(|| base_defaults.browser.clone()), + browser: { + let chrome_cache_dir = instance_dir.join("chrome_cache"); + toml.defaults + .browser + .map(|b| { + let base = &base_defaults.browser; + BrowserConfig { + enabled: b.enabled.unwrap_or(base.enabled), + headless: b.headless.unwrap_or(base.headless), + evaluate_enabled: b.evaluate_enabled.unwrap_or(base.evaluate_enabled), + executable_path: b + .executable_path + .or_else(|| base.executable_path.clone()), + screenshot_dir: b + .screenshot_dir + .map(PathBuf::from) + .or_else(|| base.screenshot_dir.clone()), + chrome_cache_dir: chrome_cache_dir.clone(), + } + }) + .unwrap_or_else(|| BrowserConfig { + chrome_cache_dir, + ..base_defaults.browser.clone() + }) + }, mcp: default_mcp, brave_search_key: toml .defaults @@ -4935,6 +4950,7 @@ impl Config { .screenshot_dir .map(PathBuf::from) .or_else(|| defaults.browser.screenshot_dir.clone()), + chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), mcp: match a.mcp { Some(mcp_servers) => Some( diff --git a/src/tools/browser.rs b/src/tools/browser.rs index f6e692f9b..579c9b5c3 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -6,6 +6,7 @@ use crate::config::BrowserConfig; use chromiumoxide::browser::{Browser, BrowserConfig as ChromeConfig}; +use chromiumoxide::fetcher::{BrowserFetcher, BrowserFetcherOptions}; use chromiumoxide::page::ScreenshotParams; use chromiumoxide_cdp::cdp::browser_protocol::accessibility::{ EnableParams as AxEnableParams, GetFullAxTreeParams, @@ -22,7 +23,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -145,6 +146,34 @@ struct BrowserState { element_refs: HashMap, /// Counter for generating element refs. next_ref: usize, + /// Per-launch temp directory for Chrome's user data. Cleaned up on drop to + /// prevent stale singleton locks from blocking subsequent launches. + user_data_dir: Option, +} + +impl Drop for BrowserState { + fn drop(&mut self) { + // Browser and handler task are dropped automatically — + // chromiumoxide's Child has kill_on_drop(true). + if let Some(dir) = self.user_data_dir.take() { + // Offload sync fs cleanup to a blocking thread so we don't stall + // the tokio worker that's dropping this state. + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn_blocking(move || { + if let Err(error) = std::fs::remove_dir_all(&dir) { + tracing::debug!( + path = %dir.display(), + %error, + "failed to clean up browser user data dir" + ); + } + }); + } else { + // Dropped outside a tokio runtime (unlikely) — clean up inline. + let _ = std::fs::remove_dir_all(&dir); + } + } + } } impl std::fmt::Debug for BrowserState { @@ -180,6 +209,7 @@ impl BrowserTool { active_target: None, element_refs: HashMap::new(), next_ref: 0, + user_data_dir: None, })), config, screenshot_dir, @@ -466,29 +496,44 @@ impl Tool for BrowserTool { impl BrowserTool { async fn handle_launch(&self) -> Result { - let mut state = self.state.lock().await; - - if state.browser.is_some() { - return Ok(BrowserOutput::success("Browser already running")); + // Quick check under the lock — don't hold it across the potentially + // long resolve + launch sequence. + { + let state = self.state.lock().await; + if state.browser.is_some() { + return Ok(BrowserOutput::success("Browser already running")); + } } - let mut builder = ChromeConfig::builder().no_sandbox(); + // Resolve the Chrome executable path (may download ~150MB on first use): + // 1. Explicit config override + // 2. CHROME / CHROME_PATH env vars + // 3. chromiumoxide default detection (system PATH + well-known paths) + // 4. Auto-download via BrowserFetcher (cached in chrome_cache_dir) + let executable = resolve_chrome_executable(&self.config).await?; + + // Use a unique temp dir per launch to avoid singleton lock collisions + // when multiple workers launch browsers or a previous session crashed. + let user_data_dir = + std::env::temp_dir().join(format!("spacebot-browser-{}", uuid::Uuid::new_v4())); + + let mut builder = ChromeConfig::builder() + .no_sandbox() + .chrome_executable(&executable) + .user_data_dir(&user_data_dir); if !self.config.headless { builder = builder.with_head().window_size(1280, 900); } - if let Some(path) = &self.config.executable_path { - builder = builder.chrome_executable(path); - } - let chrome_config = builder.build().map_err(|error| { BrowserError::new(format!("failed to build browser config: {error}")) })?; tracing::info!( headless = self.config.headless, - executable = ?self.config.executable_path, + executable = %executable.display(), + user_data_dir = %user_data_dir.display(), "launching chrome" ); @@ -498,8 +543,21 @@ impl BrowserTool { let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} }); + // Re-acquire the lock only to store state. + let mut state = self.state.lock().await; + + // Guard against a concurrent launch that won the race. + if state.browser.is_some() { + // Another call launched while we were downloading/starting. Clean up + // the browser we just created and return success. + drop(browser); + let _ = std::fs::remove_dir_all(&user_data_dir); + return Ok(BrowserOutput::success("Browser already running")); + } + state.browser = Some(browser); state._handler_task = Some(handler_task); + state.user_data_dir = Some(user_data_dir); tracing::info!("browser launched"); Ok(BrowserOutput::success("Browser launched successfully")) @@ -967,6 +1025,17 @@ impl BrowserTool { state.next_ref = 0; state._handler_task = None; + // Clean up the per-launch user data dir to free disk space. + if let Some(dir) = state.user_data_dir.take() + && let Err(error) = tokio::fs::remove_dir_all(&dir).await + { + tracing::debug!( + path = %dir.display(), + %error, + "failed to clean up browser user data dir" + ); + } + tracing::info!("browser closed"); Ok(BrowserOutput::success("Browser closed")) } @@ -1108,3 +1177,88 @@ fn truncate_for_display(text: &str, max_len: usize) -> String { format!("{}...", &text[..max_len]) } } + +/// Resolve the Chrome/Chromium executable path using a layered detection chain: +/// +/// 1. Explicit config override (`executable_path` in TOML) +/// 2. `CHROME` / `CHROME_PATH` environment variables +/// 3. chromiumoxide default detection (system PATH + well-known install paths) +/// 4. Auto-download via `BrowserFetcher` (cached in `chrome_cache_dir`) +async fn resolve_chrome_executable(config: &BrowserConfig) -> Result { + // 1. Explicit config + if let Some(path) = &config.executable_path { + let path = PathBuf::from(path); + if path.exists() { + tracing::debug!(path = %path.display(), "using configured chrome executable"); + return Ok(path); + } + tracing::warn!( + path = %path.display(), + "configured executable_path does not exist, falling through to detection" + ); + } + + // 2. Environment variables + if let Some(path) = detect_chrome_from_env() { + tracing::debug!(path = %path.display(), "using chrome from environment variable"); + return Ok(path); + } + + // 3. chromiumoxide default detection (PATH lookup + well-known install paths) + if let Ok(path) = chromiumoxide::detection::default_executable(Default::default()) { + tracing::debug!(path = %path.display(), "using system-detected chrome"); + return Ok(path); + } + + // 4. Auto-download via fetcher + tracing::info!( + cache_dir = %config.chrome_cache_dir.display(), + "no system Chrome found, downloading via fetcher" + ); + fetch_chrome(&config.chrome_cache_dir).await +} + +/// Check `CHROME` and `CHROME_PATH` environment variables for a Chrome binary. +fn detect_chrome_from_env() -> Option { + for variable in ["CHROME", "CHROME_PATH"] { + if let Ok(value) = std::env::var(variable) { + let path = PathBuf::from(&value); + if path.exists() { + return Some(path); + } + } + } + None +} + +/// Download Chromium using chromiumoxide's built-in fetcher. +/// The binary is cached in `cache_dir` and reused on subsequent launches. +async fn fetch_chrome(cache_dir: &Path) -> Result { + tokio::fs::create_dir_all(cache_dir) + .await + .map_err(|error| { + BrowserError::new(format!( + "failed to create chrome cache dir {}: {error}", + cache_dir.display() + )) + })?; + + let options = BrowserFetcherOptions::builder() + .with_path(cache_dir) + .build() + .map_err(|error| { + BrowserError::new(format!("failed to build browser fetcher options: {error}")) + })?; + + let fetcher = BrowserFetcher::new(options); + let info = fetcher + .fetch() + .await + .map_err(|error| BrowserError::new(format!("failed to download chrome: {error}")))?; + + tracing::info!( + path = %info.executable_path.display(), + "chrome downloaded and cached" + ); + Ok(info.executable_path) +} diff --git a/src/update.rs b/src/update.rs index 9815494a3..8bddbe8b3 100644 --- a/src/update.rs +++ b/src/update.rs @@ -54,21 +54,6 @@ fn is_running_in_container() -> bool { .any(|marker| cgroup.contains(marker)) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ImageVariant { - Slim, - Full, -} - -impl ImageVariant { - fn as_str(self) -> &'static str { - match self { - Self::Slim => "slim", - Self::Full => "full", - } - } -} - /// Result of an update check. #[derive(Debug, Clone, Serialize)] pub struct UpdateStatus { @@ -373,9 +358,8 @@ pub async fn apply_docker_update(status: &SharedUpdateStatus) -> anyhow::Result< .to_string(); // Resolve the target image: same base name, new version tag. - // e.g. ghcr.io/spacedriveapp/spacebot:v0.1.0-slim -> ghcr.io/spacedriveapp/spacebot:v0.2.0-slim - let runtime_variant = detect_runtime_image_variant(); - let target_image = resolve_target_image(¤t_image, latest_version, runtime_variant); + // e.g. ghcr.io/spacedriveapp/spacebot:v0.1.0 -> ghcr.io/spacedriveapp/spacebot:v0.2.0 + let target_image = resolve_target_image(¤t_image, latest_version); tracing::info!( current_image = %current_image, @@ -565,14 +549,12 @@ fn get_own_container_id() -> anyhow::Result { /// Given a current image reference and a new version, produce the target image tag. /// /// Examples: -/// - `ghcr.io/spacedriveapp/spacebot:v0.1.0-slim` + `0.2.0` -> `ghcr.io/spacedriveapp/spacebot:v0.2.0-slim` -/// - `ghcr.io/spacedriveapp/spacebot:slim` + `0.2.0` -> `ghcr.io/spacedriveapp/spacebot:v0.2.0-slim` -/// - `ghcr.io/spacedriveapp/spacebot:latest` + `0.2.0` + full runtime -> `ghcr.io/spacedriveapp/spacebot:v0.2.0-full` -fn resolve_target_image( - current_image: &str, - new_version: &str, - runtime_variant: Option, -) -> String { +/// - `ghcr.io/spacedriveapp/spacebot:v0.1.0` + `0.2.0` -> `ghcr.io/spacedriveapp/spacebot:v0.2.0` +/// - `ghcr.io/spacedriveapp/spacebot:latest` + `0.2.0` -> `ghcr.io/spacedriveapp/spacebot:v0.2.0` +/// - `ghcr.io/spacedriveapp/spacebot:v0.1.0-full` + `0.2.0` -> `ghcr.io/spacedriveapp/spacebot:v0.2.0` +/// +/// Legacy `-slim`/`-full` suffixes are stripped during migration to the unified image. +fn resolve_target_image(current_image: &str, new_version: &str) -> String { let image_without_digest = current_image .split_once('@') .map(|(name, _)| name) @@ -581,46 +563,14 @@ fn resolve_target_image( let last_slash = image_without_digest.rfind('/'); let last_colon = image_without_digest.rfind(':'); - let (base, tag) = match last_colon { - Some(colon) if last_slash.is_none_or(|slash| colon > slash) => ( - &image_without_digest[..colon], - &image_without_digest[colon + 1..], - ), - _ => (image_without_digest, "latest"), + let base = match last_colon { + Some(colon) if last_slash.is_none_or(|slash| colon > slash) => { + &image_without_digest[..colon] + } + _ => image_without_digest, }; - let variant = detect_variant_from_tag(tag) - .or(runtime_variant) - .unwrap_or(ImageVariant::Slim); - - format!("{}:v{}-{}", base, new_version, variant.as_str()) -} - -fn detect_variant_from_tag(tag: &str) -> Option { - if tag.contains("full") { - Some(ImageVariant::Full) - } else if tag.contains("slim") { - Some(ImageVariant::Slim) - } else { - None - } -} - -fn detect_runtime_image_variant() -> Option { - if let Ok(chrome_path) = std::env::var("CHROME_PATH") - && !chrome_path.is_empty() - && std::path::Path::new(&chrome_path).exists() - { - return Some(ImageVariant::Full); - } - - if std::path::Path::new("/usr/bin/chromium").exists() - || std::path::Path::new("/usr/bin/chromium-browser").exists() - { - return Some(ImageVariant::Full); - } - - None + format!("{base}:v{new_version}") } #[cfg(test)] @@ -637,69 +587,35 @@ mod tests { #[test] fn test_resolve_target_image() { + // Versioned tag assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot:v0.1.0-slim", - "0.2.0", - Some(ImageVariant::Full) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-slim" + resolve_target_image("ghcr.io/spacedriveapp/spacebot:v0.1.0", "0.2.0"), + "ghcr.io/spacedriveapp/spacebot:v0.2.0" ); + // Latest tag assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot:v0.1.0-full", - "0.2.0", - Some(ImageVariant::Slim) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-full" + resolve_target_image("ghcr.io/spacedriveapp/spacebot:latest", "0.2.0"), + "ghcr.io/spacedriveapp/spacebot:v0.2.0" ); + // Legacy slim tag (strips variant) assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot:latest", - "0.2.0", - Some(ImageVariant::Full) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-full" + resolve_target_image("ghcr.io/spacedriveapp/spacebot:v0.1.0-slim", "0.2.0"), + "ghcr.io/spacedriveapp/spacebot:v0.2.0" ); + // Legacy full tag (strips variant) assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot:latest", - "0.2.0", - Some(ImageVariant::Slim) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-slim" + resolve_target_image("ghcr.io/spacedriveapp/spacebot:v0.1.0-full", "0.2.0"), + "ghcr.io/spacedriveapp/spacebot:v0.2.0" ); + // Custom registry with port in host assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot:slim", - "0.2.0", - Some(ImageVariant::Full) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-slim" + resolve_target_image("registry.local:5000/spacebot", "0.2.0"), + "registry.local:5000/spacebot:v0.2.0" ); + // Digest reference assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot:v0.1.0", - "0.2.0", - Some(ImageVariant::Full) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-full" - ); - assert_eq!( - resolve_target_image( - "registry.local:5000/spacebot", - "0.2.0", - Some(ImageVariant::Full) - ), - "registry.local:5000/spacebot:v0.2.0-full" - ); - assert_eq!( - resolve_target_image( - "ghcr.io/spacedriveapp/spacebot@sha256:abcdef", - "0.2.0", - Some(ImageVariant::Full) - ), - "ghcr.io/spacedriveapp/spacebot:v0.2.0-full" + resolve_target_image("ghcr.io/spacedriveapp/spacebot@sha256:abcdef", "0.2.0"), + "ghcr.io/spacedriveapp/spacebot:v0.2.0" ); } }