diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2472b05a0..6b368407f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,12 +122,62 @@ jobs: runner: name: Runner needs: changes - if: ${{ github.ref != 'refs/heads/cloud' && (needs.changes.outputs.runner == 'true' || github.ref == 'refs/heads/devel' || startsWith(github.ref, 'refs/tags/')) }} + # On cloud pushes, the runner is only rebuilt when runner code changed + # (avoids pointless canary fleet updates) and skips the scenario tests + # (the same commits already ran them on devel). + if: ${{ needs.changes.outputs.runner == 'true' || github.ref == 'refs/heads/devel' || startsWith(github.ref, 'refs/tags/') }} uses: ./.github/workflows/runner.yml with: mold-version: "2.34.1" zig-version: "0.13.0" zig-build-version: "0.19.3" + build-only: ${{ github.ref == 'refs/heads/cloud' }} + + # Publish the cloud branch runner build to the rolling `canary` prerelease. + # Canary channel runners poll this release's checksums and self-update when + # the published binary changes. + publish_runner_canary: + name: Publish Runner Canary + permissions: + contents: write + needs: runner + if: ${{ !failure() && !cancelled() && github.ref == 'refs/heads/cloud' && needs.runner.result == 'success' }} + runs-on: ubuntu-22.04 + steps: + - name: Download Runner Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: runner-canary-linux-* + merge-multiple: true + - name: Publish to canary prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + if ! gh release view canary > /dev/null 2>&1; then + gh release create canary \ + --prerelease \ + --latest=false \ + --target "$GITHUB_SHA" \ + --title "Runner Canary" \ + --notes "Rolling prerelease of the runner, rebuilt on cloud branch deploys." + fi + # Binaries first, checksums last: the server triggers updates off + # the .sha256 files, so a partial upload never advertises a binary + # that is not yet in place. + gh release upload canary --clobber \ + runner-canary-linux-x86-64 \ + runner-canary-linux-arm-64 + gh release upload canary --clobber \ + runner-canary-linux-x86-64.sha256 \ + runner-canary-linux-arm-64.sha256 + # Keep the canary tag pointing at the commit the assets were built + # from. Downloads are by asset, so this is purely for humans + # inspecting the release; tag moves do not trigger CI (tags: v**). + gh api --method PATCH "repos/{owner}/{repo}/git/refs/tags/canary" \ + -f "sha=$GITHUB_SHA" -F force=true > /dev/null + gh release edit canary --notes "Rolling prerelease of the runner, rebuilt on cloud branch deploys. Currently built from $GITHUB_SHA." test: name: Test @@ -232,7 +282,7 @@ jobs: ci-success: name: CI Success if: always() - needs: [lint, cli, runner, test, build, docker, deploy, release] + needs: [lint, cli, runner, publish_runner_canary, test, build, docker, deploy, release] runs-on: ubuntu-22.04 steps: - name: Check all jobs passed @@ -249,6 +299,10 @@ jobs: echo "Runner failed" exit 1 fi + if [[ "${{ needs.publish_runner_canary.result }}" != "success" && "${{ needs.publish_runner_canary.result }}" != "skipped" ]]; then + echo "Publish Runner Canary failed" + exit 1 + fi if [[ "${{ needs.test.result }}" != "success" && "${{ needs.test.result }}" != "skipped" ]]; then echo "Test failed" exit 1 diff --git a/.github/workflows/runner.yml b/.github/workflows/runner.yml index 46878e963..bccb999e1 100644 --- a/.github/workflows/runner.yml +++ b/.github/workflows/runner.yml @@ -12,6 +12,13 @@ on: zig-build-version: type: string required: true + # Skip the KVM scenario tests and only build the binaries. + # Used on the cloud branch, where the same commits already ran the + # scenario tests on devel. + build-only: + type: boolean + required: false + default: false env: CARGO_TERM_COLOR: always @@ -20,6 +27,7 @@ env: jobs: test_runner_scenarios: name: Test Runner Scenarios + if: ${{ !inputs.build-only }} runs-on: ubuntu-22.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -71,6 +79,11 @@ jobs: HEAD_REF: ${{ github.head_ref || github.ref_name }} run: | VERSION="$HEAD_REF" + # Cloud branch builds are versioned as `canary` for the rolling + # canary prerelease that Bencher Cloud runners track. + if [[ "$GITHUB_REF" == "refs/heads/cloud" ]]; then + VERSION="canary" + fi echo "RUNNER_VERSION=${VERSION//\//-}" >> "$GITHUB_ENV" - name: Setup Rust uses: ./.github/actions/setup-rust diff --git a/Cargo.lock b/Cargo.lock index 455909bbb..2ce4a596c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,7 @@ dependencies = [ "slog", "tempfile", "tokio", + "url", ] [[package]] @@ -1582,6 +1583,7 @@ dependencies = [ "bencher_runner", "camino", "clap", + "pretty_assertions", "rustls", "thiserror 2.0.18", "url", diff --git a/lib/bencher_api_tests/Cargo.toml b/lib/bencher_api_tests/Cargo.toml index 1b0cf748d..779bef401 100644 --- a/lib/bencher_api_tests/Cargo.toml +++ b/lib/bencher_api_tests/Cargo.toml @@ -45,6 +45,7 @@ sha2 = { workspace = true, optional = true } slog.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +url.workspace = true [lints] workspace = true diff --git a/lib/bencher_api_tests/src/context.rs b/lib/bencher_api_tests/src/context.rs index a569d97d4..f376738bd 100644 --- a/lib/bencher_api_tests/src/context.rs +++ b/lib/bencher_api_tests/src/context.rs @@ -44,13 +44,13 @@ pub struct TestServer { impl TestServer { /// Create a new test server with default settings. pub async fn new() -> Self { - Self::build(None, None, None).await + Self::build(None, None, None, None).await } /// Create a new test server with custom upload timeout and max body size. #[cfg(feature = "plus")] pub async fn new_with_limits(upload_timeout: u64, max_body_size: u64) -> Self { - Self::build(Some(upload_timeout), Some(max_body_size), None).await + Self::build(Some(upload_timeout), Some(max_body_size), None, None).await } /// Create a new test server with custom upload timeout, max body size, and injectable clock. @@ -60,7 +60,13 @@ impl TestServer { max_body_size: u64, clock: bencher_json::Clock, ) -> Self { - Self::build(Some(upload_timeout), Some(max_body_size), Some(clock)).await + Self::build(Some(upload_timeout), Some(max_body_size), Some(clock), None).await + } + + /// Create a new test server with a custom runner self-update base URL. + #[cfg(feature = "plus")] + pub async fn new_with_runner_update_base_url(base_url: url::Url) -> Self { + Self::build(None, None, None, Some(base_url)).await } #[cfg(feature = "plus")] @@ -73,6 +79,7 @@ impl TestServer { upload_timeout: Option, max_body_size: Option, clock: Option, + runner_update_base_url: Option, ) -> Self { // Create logger early so it can be used for OCI storage let log_config = ConfigLogging::StderrTerminal { @@ -147,6 +154,7 @@ impl TestServer { heartbeat_timeout: std::time::Duration::from_secs(5), job_timeout_grace_period: std::time::Duration::from_mins(1), heartbeat_tasks: bencher_schema::context::HeartbeatTasks::new(), + runner_update: bencher_schema::context::RunnerUpdate::new(runner_update_base_url), shutdown: bencher_schema::context::CancellationToken::new(), }; @@ -164,6 +172,7 @@ impl TestServer { upload_timeout: Option, max_body_size: Option, _clock: Option<()>, + _runner_update_base_url: Option, ) -> Self { // Create logger early so it can be used for OCI storage let log_config = ConfigLogging::StderrTerminal { diff --git a/lib/bencher_config/src/config_tx.rs b/lib/bencher_config/src/config_tx.rs index 73ec6fe82..a1517a0c2 100644 --- a/lib/bencher_config/src/config_tx.rs +++ b/lib/bencher_config/src/config_tx.rs @@ -85,6 +85,9 @@ pub enum ConfigTxError { #[error("Failed to get server ID: {0}")] ServerId(dropshot::HttpError), #[cfg(feature = "plus")] + #[error("Invalid runner update base URL: {0}")] + RunnerUpdateBaseUrl(bencher_json::ValidError), + #[cfg(feature = "plus")] #[error("Failed to spawn stats: {0}")] SpawnStats(dropshot::HttpError), } @@ -299,6 +302,13 @@ async fn into_context( u64::from(u32::from(r.job_timeout_grace_period)) }), ); + #[cfg(feature = "plus")] + let runner_update_base_url = plus + .as_ref() + .and_then(|p| p.runners.as_ref()) + .and_then(|r| r.update_base_url.clone()) + .map(|url| url.try_into().map_err(ConfigTxError::RunnerUpdateBaseUrl)) + .transpose()?; info!(log, "Configuring Bencher Plus"); #[cfg(feature = "plus")] @@ -369,6 +379,8 @@ async fn into_context( #[cfg(feature = "plus")] heartbeat_tasks: bencher_schema::context::HeartbeatTasks::new(), #[cfg(feature = "plus")] + runner_update: bencher_schema::context::RunnerUpdate::new(runner_update_base_url), + #[cfg(feature = "plus")] shutdown: bencher_schema::context::CancellationToken::new(), }) } diff --git a/lib/bencher_json/src/lib.rs b/lib/bencher_json/src/lib.rs index cc2ca6742..0469d91d3 100644 --- a/lib/bencher_json/src/lib.rs +++ b/lib/bencher_json/src/lib.rs @@ -43,7 +43,7 @@ pub mod urlencoded; pub mod user; #[cfg(feature = "plus")] -pub use bencher_valid::{Architecture, OperatingSystem, Sandbox, Sha256}; +pub use bencher_valid::{Architecture, OperatingSystem, Sandbox, Sha256, UpdateChannel}; pub use big_int::BigInt; pub use clock::Clock; pub use organization::{ diff --git a/lib/bencher_json/src/runner/websocket.rs b/lib/bencher_json/src/runner/websocket.rs index ebf4e12ce..a6c723354 100644 --- a/lib/bencher_json/src/runner/websocket.rs +++ b/lib/bencher_json/src/runner/websocket.rs @@ -1,6 +1,4 @@ -#[cfg(feature = "plus")] -use bencher_valid::Sha256; -use bencher_valid::{Architecture, OperatingSystem, PollTimeout}; +use bencher_valid::{Architecture, OperatingSystem, PollTimeout, Sha256, UpdateChannel}; #[cfg(feature = "schema")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -60,6 +58,12 @@ pub struct JsonRunnerMetadata { pub arch: Architecture, /// Runner binary version pub version: String, + /// Update channel (absent means stable) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, + /// SHA-256 checksum of the running runner binary + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checksum: Option, } /// Messages sent from the server to the runner over the WebSocket channel. @@ -136,6 +140,8 @@ mod tests { os: OperatingSystem::Linux, arch: Architecture::X86_64, version: test_version(), + channel: None, + checksum: None, }), }; let json = serde_json::to_string(&msg).unwrap(); @@ -150,6 +156,8 @@ mod tests { assert_eq!(runner.os, OperatingSystem::Linux); assert_eq!(runner.arch, Architecture::X86_64); assert_eq!(runner.version, test_version()); + assert!(runner.channel.is_none()); + assert!(runner.checksum.is_none()); }, other => panic!("Expected Ready, got {other:?}"), } @@ -163,6 +171,8 @@ mod tests { os: OperatingSystem::Linux, arch: Architecture::Aarch64, version: test_version(), + channel: None, + checksum: None, }), }; let json = serde_json::to_string(&msg).unwrap(); @@ -177,11 +187,79 @@ mod tests { assert_eq!(runner.os, OperatingSystem::Linux); assert_eq!(runner.arch, Architecture::Aarch64); assert_eq!(runner.version, test_version()); + assert!(runner.channel.is_none()); + assert!(runner.checksum.is_none()); + }, + other => panic!("Expected Ready, got {other:?}"), + } + } + + #[test] + fn ready_canary_channel_roundtrip() { + let checksum: Sha256 = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + .parse() + .unwrap(); + let msg = RunnerMessage::Ready { + poll_timeout: None, + runner: Some(JsonRunnerMetadata { + os: OperatingSystem::Linux, + arch: Architecture::X86_64, + version: test_version(), + channel: Some(UpdateChannel::Canary), + checksum: Some(checksum.clone()), + }), + }; + let json = serde_json::to_string(&msg).unwrap(); + let deserialized: RunnerMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + RunnerMessage::Ready { runner, .. } => { + let runner = runner.unwrap(); + assert_eq!(runner.channel, Some(UpdateChannel::Canary)); + assert_eq!(runner.checksum, Some(checksum)); + }, + other => panic!("Expected Ready, got {other:?}"), + } + } + + #[test] + fn ready_legacy_metadata_deserializes() { + // A pre-channel runner sends only os/arch/version; the new fields + // must default to the stable channel with no checksum. + let json = format!( + r#"{{"event":"ready","runner":{{"os":"linux","arch":"x86_64","version":"{version}"}}}}"#, + version = test_version(), + ); + let deserialized: RunnerMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + RunnerMessage::Ready { runner, .. } => { + let runner = runner.unwrap(); + assert_eq!(runner.version, test_version()); + assert!(runner.channel.is_none()); + assert!(runner.checksum.is_none()); }, other => panic!("Expected Ready, got {other:?}"), } } + #[test] + fn ready_stable_metadata_wire_format_is_legacy() { + // A stable runner without a checksum must serialize exactly like a + // pre-channel runner: no channel or checksum fields on the wire. + let msg = RunnerMessage::Ready { + poll_timeout: None, + runner: Some(JsonRunnerMetadata { + os: OperatingSystem::Linux, + arch: Architecture::X86_64, + version: test_version(), + channel: None, + checksum: None, + }), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(!json.contains("channel"), "unexpected channel in {json}"); + assert!(!json.contains("checksum"), "unexpected checksum in {json}"); + } + #[test] fn ready_no_runner_metadata_roundtrip() { let msg = RunnerMessage::Ready { diff --git a/lib/bencher_json/src/system/config/plus/runners.rs b/lib/bencher_json/src/system/config/plus/runners.rs index 48ceb1836..504c9a41d 100644 --- a/lib/bencher_json/src/system/config/plus/runners.rs +++ b/lib/bencher_json/src/system/config/plus/runners.rs @@ -1,6 +1,6 @@ use std::sync::LazyLock; -use bencher_valid::{GracePeriod, HeartbeatTimeout}; +use bencher_valid::{GracePeriod, HeartbeatTimeout, Url}; #[cfg(feature = "schema")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -35,6 +35,10 @@ pub struct JsonRunners { /// Defaults to 60 seconds. #[serde(default = "default_job_timeout_grace_period")] pub job_timeout_grace_period: GracePeriod, + /// Base URL for runner self-update downloads. + /// Defaults to the Bencher GitHub Releases download URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub update_base_url: Option, } fn default_heartbeat_timeout() -> HeartbeatTimeout { diff --git a/lib/bencher_schema/src/context/mod.rs b/lib/bencher_schema/src/context/mod.rs index 780c15b4f..99132aec3 100644 --- a/lib/bencher_schema/src/context/mod.rs +++ b/lib/bencher_schema/src/context/mod.rs @@ -28,6 +28,8 @@ mod messenger; mod rate_limiting; mod rbac; #[cfg(feature = "plus")] +mod runner_update; +#[cfg(feature = "plus")] mod stats; #[cfg(feature = "plus")] @@ -44,6 +46,8 @@ pub use messenger::{Body, ButtonBody, Email, Message, Messenger, NewUserBody}; pub use rate_limiting::{HeaderMap, RateLimiting, RateLimitingError}; pub use rbac::{Rbac, RbacError}; #[cfg(feature = "plus")] +pub use runner_update::RunnerUpdate; +#[cfg(feature = "plus")] pub use stats::StatsSettings; #[cfg(feature = "plus")] pub use tokio_util::sync::CancellationToken; @@ -84,6 +88,9 @@ pub struct ApiContext { pub job_timeout_grace_period: std::time::Duration, #[cfg(feature = "plus")] pub heartbeat_tasks: HeartbeatTasks, + /// Runner self-update URL construction and cloud channel checksum cache. + #[cfg(feature = "plus")] + pub runner_update: RunnerUpdate, /// Cancellation signal tripped on graceful shutdown so long-lived handlers (the runner WebSocket /// channel) can wind down and let `server.close()` complete. #[cfg(feature = "plus")] diff --git a/lib/bencher_schema/src/context/runner_update.rs b/lib/bencher_schema/src/context/runner_update.rs new file mode 100644 index 000000000..5895fd98b --- /dev/null +++ b/lib/bencher_schema/src/context/runner_update.rs @@ -0,0 +1,245 @@ +use std::collections::HashMap; + +use bencher_json::{Architecture, Clock, Sha256}; +use url::Url; + +/// Base URL for stable channel runner downloads (GitHub Releases). +// Trailing slash is required: Url::join appends to the last path segment, +// so without it the join would replace "download" instead of extending it. +const DEFAULT_UPDATE_BASE_URL: &str = "https://github.com/bencherdev/bencher/releases/download/"; + +/// Rolling prerelease tag that tracks the `cloud` branch. +/// A tag literally named `cloud` would be ambiguous with the `cloud` branch +/// in git short-refname resolution, so the rolling tag is named `canary`. +const CANARY_RELEASE_TAG: &str = "canary"; + +/// How long a fetched canary channel checksum stays fresh. +/// This is also the upper bound on rollout propagation delay: after a new +/// canary publish, runners may be offered the previous build until the cached +/// checksum expires. A runner offered a stale checksum during that window +/// downloads the newer clobbered binary, fails verification, and retries at +/// its next `Ready`, so transient "checksum mismatch" runner logs right after +/// a deploy are expected and self-healing. On expiry, concurrent `Ready` +/// polls may each re-fetch (no single-flight); the responses are tiny and +/// the canary fleet is small, so the duplicate fetches are not worth +/// guarding against. +const CANARY_CHECKSUM_TTL_SECS: i64 = 120; + +/// Runner self-update state: download URL construction and a TTL cache of the +/// published canary channel checksums, one per architecture. +pub struct RunnerUpdate { + base_url: Url, + ttl_secs: i64, + cache: tokio::sync::Mutex>, +} + +struct CanaryChecksum { + checksum: Sha256, + fetched: i64, +} + +impl RunnerUpdate { + pub fn new(base_url: Option) -> Self { + #[expect(clippy::expect_used, reason = "constant URL literal, infallible")] + let base_url = base_url.map_or_else( + || { + DEFAULT_UPDATE_BASE_URL + .parse() + .expect("valid default update base URL") + }, + ensure_trailing_slash, + ); + Self { + base_url, + ttl_secs: CANARY_CHECKSUM_TTL_SECS, + cache: tokio::sync::Mutex::new(HashMap::new()), + } + } + + /// Download URL for a stable channel runner binary published under a version tag. + pub fn stable_url(&self, version: &str, arch: Architecture) -> Result { + let tag = format!("v{version}"); + let artifact = format!("runner-{tag}-{slug}", slug = arch.linux_artifact_slug()); + self.base_url.join(&format!("{tag}/{artifact}")) + } + + /// Download URL for the rolling canary channel runner binary. + pub fn canary_url(&self, arch: Architecture) -> Result { + let artifact = format!("runner-canary-{slug}", slug = arch.linux_artifact_slug()); + self.base_url + .join(&format!("{CANARY_RELEASE_TAG}/{artifact}")) + } + + /// Return the cached canary channel checksum for `arch`, if still fresh. + pub async fn cached_canary_checksum( + &self, + clock: &Clock, + arch: Architecture, + ) -> Option { + let cache = self.cache.lock().await; + let entry = cache.get(&arch)?; + let age = clock.timestamp().saturating_sub(entry.fetched); + (age < self.ttl_secs).then(|| entry.checksum.clone()) + } + + /// Cache a freshly fetched canary channel checksum for `arch`. + pub async fn store_canary_checksum(&self, clock: &Clock, arch: Architecture, checksum: Sha256) { + let mut cache = self.cache.lock().await; + cache.insert( + arch, + CanaryChecksum { + checksum, + fetched: clock.timestamp(), + }, + ); + } +} + +fn ensure_trailing_slash(mut url: Url) -> Url { + if !url.path().ends_with('/') { + let path = format!("{path}/", path = url.path()); + url.set_path(&path); + } + url +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bencher_json::DateTime; + use pretty_assertions::assert_eq; + + use super::*; + + fn test_checksum() -> Sha256 { + "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + .parse() + .unwrap() + } + + fn other_checksum() -> Sha256 { + "b3a8e0e1f9ab1bfe3a36f231f676f78bb30a519d2b21e6c530c0eee8ebb4a5d0" + .parse() + .unwrap() + } + + /// A clock frozen at `DateTime::TEST` plus the given offset in seconds. + fn clock_at(offset_secs: i64) -> Clock { + Clock::Custom(Arc::new(move || { + DateTime::try_from(DateTime::TEST.timestamp() + offset_secs).unwrap() + })) + } + + #[test] + fn stable_url_construction() { + let update = RunnerUpdate::new(None); + let url = update.stable_url("0.6.8", Architecture::X86_64).unwrap(); + assert_eq!( + url.as_str(), + "https://github.com/bencherdev/bencher/releases/download/v0.6.8/runner-v0.6.8-linux-x86-64" + ); + let url = update.stable_url("0.6.8", Architecture::Aarch64).unwrap(); + assert_eq!( + url.as_str(), + "https://github.com/bencherdev/bencher/releases/download/v0.6.8/runner-v0.6.8-linux-arm-64" + ); + } + + #[test] + fn canary_url_construction() { + let update = RunnerUpdate::new(None); + let url = update.canary_url(Architecture::X86_64).unwrap(); + assert_eq!( + url.as_str(), + "https://github.com/bencherdev/bencher/releases/download/canary/runner-canary-linux-x86-64" + ); + let url = update.canary_url(Architecture::Aarch64).unwrap(); + assert_eq!( + url.as_str(), + "https://github.com/bencherdev/bencher/releases/download/canary/runner-canary-linux-arm-64" + ); + } + + #[test] + fn custom_base_url_without_trailing_slash() { + let base: Url = "http://localhost:8080/downloads".parse().unwrap(); + let update = RunnerUpdate::new(Some(base)); + let url = update.canary_url(Architecture::X86_64).unwrap(); + assert_eq!( + url.as_str(), + "http://localhost:8080/downloads/canary/runner-canary-linux-x86-64" + ); + } + + #[tokio::test] + async fn cache_miss_when_empty() { + let update = RunnerUpdate::new(None); + let clock = clock_at(0); + assert_eq!( + None, + update + .cached_canary_checksum(&clock, Architecture::X86_64) + .await + ); + } + + #[tokio::test] + async fn cache_hit_within_ttl() { + let update = RunnerUpdate::new(None); + let clock = clock_at(0); + update + .store_canary_checksum(&clock, Architecture::X86_64, test_checksum()) + .await; + + let clock = clock_at(CANARY_CHECKSUM_TTL_SECS - 1); + assert_eq!( + Some(test_checksum()), + update + .cached_canary_checksum(&clock, Architecture::X86_64) + .await + ); + } + + #[tokio::test] + async fn cache_expires_at_ttl() { + let update = RunnerUpdate::new(None); + let clock = clock_at(0); + update + .store_canary_checksum(&clock, Architecture::X86_64, test_checksum()) + .await; + + let clock = clock_at(CANARY_CHECKSUM_TTL_SECS); + assert_eq!( + None, + update + .cached_canary_checksum(&clock, Architecture::X86_64) + .await + ); + } + + #[tokio::test] + async fn cache_is_per_architecture() { + let update = RunnerUpdate::new(None); + let clock = clock_at(0); + update + .store_canary_checksum(&clock, Architecture::X86_64, test_checksum()) + .await; + update + .store_canary_checksum(&clock, Architecture::Aarch64, other_checksum()) + .await; + + assert_eq!( + Some(test_checksum()), + update + .cached_canary_checksum(&clock, Architecture::X86_64) + .await + ); + assert_eq!( + Some(other_checksum()), + update + .cached_canary_checksum(&clock, Architecture::Aarch64) + .await + ); + } +} diff --git a/lib/bencher_valid/src/error.rs b/lib/bencher_valid/src/error.rs index ccf80cc2d..59b9ff9fe 100644 --- a/lib/bencher_valid/src/error.rs +++ b/lib/bencher_valid/src/error.rs @@ -101,6 +101,9 @@ pub enum ValidError { #[cfg(feature = "plus")] #[error("Failed to validate sandbox: {0}")] Sandbox(String), + #[cfg(feature = "plus")] + #[error("Failed to validate update channel: {0}")] + UpdateChannel(String), #[error("Failed to validate project key: {0}")] ProjectKey(String), #[error("Failed to validate project key hash: {0}")] diff --git a/lib/bencher_valid/src/lib.rs b/lib/bencher_valid/src/lib.rs index 2f4cd0fb0..c134f31b1 100644 --- a/lib/bencher_valid/src/lib.rs +++ b/lib/bencher_valid/src/lib.rs @@ -56,7 +56,7 @@ pub use plus::{ Architecture, CardBrand, CardCvc, CardNumber, Cpu, Disk, Entitlements, ExpirationMonth, ExpirationYear, GracePeriod, HeartbeatTimeout, ImageReference, LastFour, LicensedPlanId, Memory, MeteredPlanId, OperatingSystem, PlanLevel, PlanStatus, PollTimeout, RecaptchaAction, - RecaptchaScore, RunnerKey, RunnerKeyHash, Sandbox, Sha256, Timeout, + RecaptchaScore, RunnerKey, RunnerKeyHash, Sandbox, Sha256, Timeout, UpdateChannel, }; pub use resource_id::{IntoResourceId, ResourceId}; pub use resource_name::ResourceName; diff --git a/lib/bencher_valid/src/plus/mod.rs b/lib/bencher_valid/src/plus/mod.rs index fe0569efd..b4ee0f073 100644 --- a/lib/bencher_valid/src/plus/mod.rs +++ b/lib/bencher_valid/src/plus/mod.rs @@ -25,6 +25,7 @@ mod runner_key_hash; mod sandbox; mod sha256; mod timeout; +mod update_channel; mod year; pub use architecture::Architecture; @@ -52,4 +53,5 @@ pub use runner_key_hash::RunnerKeyHash; pub use sandbox::Sandbox; pub use sha256::Sha256; pub use timeout::Timeout; +pub use update_channel::UpdateChannel; pub use year::ExpirationYear; diff --git a/lib/bencher_valid/src/plus/update_channel.rs b/lib/bencher_valid/src/plus/update_channel.rs new file mode 100644 index 000000000..8ea280933 --- /dev/null +++ b/lib/bencher_valid/src/plus/update_channel.rs @@ -0,0 +1,125 @@ +use std::{fmt, str::FromStr}; + +#[cfg(feature = "schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::ValidError; + +const STABLE: &str = "stable"; +const CANARY: &str = "canary"; + +#[typeshare::typeshare] +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +// `rename_all` has no serde effect (serialization goes through the `String` +// conversions) but typeshare reads it to emit lowercase TypeScript enum values. +#[serde(try_from = "String", into = "String", rename_all = "snake_case")] +pub enum UpdateChannel { + #[default] + Stable, + Canary, +} + +impl fmt::Display for UpdateChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + +impl TryFrom for UpdateChannel { + type Error = ValidError; + + fn try_from(update_channel: String) -> Result { + match update_channel.as_str() { + STABLE => Ok(Self::Stable), + CANARY => Ok(Self::Canary), + _ => Err(ValidError::UpdateChannel(update_channel)), + } + } +} + +impl FromStr for UpdateChannel { + type Err = ValidError; + + fn from_str(update_channel: &str) -> Result { + Self::try_from(update_channel.to_owned()) + } +} + +impl AsRef for UpdateChannel { + fn as_ref(&self) -> &str { + match self { + Self::Stable => STABLE, + Self::Canary => CANARY, + } + } +} + +impl From for String { + fn from(update_channel: UpdateChannel) -> Self { + update_channel.as_ref().to_owned() + } +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr( + not(any(feature = "wasm", test)), + expect(dead_code, reason = "exported only for wasm and tests") +)] +pub fn is_valid_update_channel(update_channel: &str) -> bool { + matches!(update_channel, STABLE | CANARY) +} + +#[cfg(test)] +mod tests { + use super::{UpdateChannel, is_valid_update_channel}; + use pretty_assertions::assert_eq; + + #[test] + fn update_channel_valid() { + assert_eq!(true, is_valid_update_channel("stable")); + assert_eq!(true, is_valid_update_channel("canary")); + } + + #[test] + fn update_channel_invalid() { + assert_eq!(false, is_valid_update_channel("")); + assert_eq!(false, is_valid_update_channel("nightly")); + assert_eq!(false, is_valid_update_channel("Stable")); + assert_eq!(false, is_valid_update_channel("Canary")); + assert_eq!(false, is_valid_update_channel(" stable")); + assert_eq!(false, is_valid_update_channel("canary ")); + } + + #[test] + fn update_channel_default() { + assert_eq!(UpdateChannel::Stable, UpdateChannel::default()); + } + + #[test] + fn update_channel_serde_roundtrip() { + let channel: UpdateChannel = serde_json::from_str("\"stable\"").unwrap(); + assert_eq!(channel, UpdateChannel::Stable); + let json = serde_json::to_string(&channel).unwrap(); + assert_eq!(json, "\"stable\""); + + let channel: UpdateChannel = serde_json::from_str("\"canary\"").unwrap(); + assert_eq!(channel, UpdateChannel::Canary); + let json = serde_json::to_string(&channel).unwrap(); + assert_eq!(json, "\"canary\""); + + serde_json::from_str::("\"invalid\"").unwrap_err(); + } + + #[test] + fn update_channel_from_str_roundtrip() { + for channel in [UpdateChannel::Stable, UpdateChannel::Canary] { + let parsed: UpdateChannel = channel.to_string().parse().unwrap(); + assert_eq!(channel, parsed); + } + "nightly".parse::().unwrap_err(); + } +} diff --git a/plus/api_runners/src/channel.rs b/plus/api_runners/src/channel.rs index 204f9d8b9..6e78fceda 100644 --- a/plus/api_runners/src/channel.rs +++ b/plus/api_runners/src/channel.rs @@ -1282,37 +1282,10 @@ impl Drop for RunnerStateGuard { } } -// Trailing slash is required: Url::join appends to the last path segment, -// so without it the join would replace "download" instead of extending it. -#[expect( - clippy::expect_used, - reason = "Constant URL literal, infallible at runtime" -)] -static RUNNER_RELEASE_BASE_URL: std::sync::LazyLock = std::sync::LazyLock::new(|| { - "https://github.com/bencherdev/bencher/releases/download/" - .parse() - .expect("valid base URL") -}); - -/// Construct the GitHub Releases download URL for a runner binary. -fn runner_download_url( - version: &str, - arch: bencher_json::Architecture, -) -> Result { - let tag = format!("v{version}"); - let artifact = format!("runner-{tag}-{}", arch.linux_artifact_slug()); - let path = format!("{tag}/{artifact}"); - RUNNER_RELEASE_BASE_URL - .join(&path) - .map_err(ChannelError::UpdateUrl) -} - const MAX_CHECKSUM_RESPONSE_BYTES: usize = 1024; /// Fetch the SHA-256 checksum for a runner binary from its companion `.sha256` file. -async fn fetch_runner_checksum( - binary_url: &url::Url, -) -> Result { +async fn fetch_runner_checksum(binary_url: url::Url) -> Result { let checksum_url = format!("{binary_url}.sha256"); let response = reqwest::get(&checksum_url) .await @@ -1385,50 +1358,32 @@ where poll_timeout, runner, } => { - return handle_ready(log, tx, poll_timeout, runner.as_ref()).await; - }, - RunnerMessage::Completed { - job: job_uuid, - results, - } => { - slog::info!(log, "Received Completed during Idle (retry after reconnect)"; "job_uuid" => %job_uuid); - if let Some(job) = - lookup_runner_job(log, context, runner_id, job_uuid).await? - { - handle_completed(log, context, &job, results).await?; - } - let ack = serde_json::to_string(&ServerMessage::Ack { - job: Some(job_uuid), - })?; - tx.send(Message::Text(ack.into())).await?; + // Boxed to keep the long-lived `runner_channel` future small; + // this runs once per poll cycle, so the allocation is cheap. + return Box::pin(handle_ready( + log, + &context.runner_update, + &context.clock, + tx, + poll_timeout, + runner.as_ref(), + fetch_runner_checksum, + )) + .await; }, - RunnerMessage::Failed { - job: job_uuid, - results, - error, - } => { - slog::info!(log, "Received Failed during Idle (retry after reconnect)"; "job_uuid" => %job_uuid, "error" => &error); - if let Some(job) = - lookup_runner_job(log, context, runner_id, job_uuid).await? - { - handle_failed(log, context, &job, results, error).await?; - } - let ack = serde_json::to_string(&ServerMessage::Ack { - job: Some(job_uuid), - })?; - tx.send(Message::Text(ack.into())).await?; - }, - RunnerMessage::Canceled { job: job_uuid } => { - slog::info!(log, "Received Canceled during Idle (retry after reconnect)"; "job_uuid" => %job_uuid); - if let Some(job) = - lookup_runner_job(log, context, runner_id, job_uuid).await? - { - handle_canceled(log, context, job.id).await?; - } - let ack = serde_json::to_string(&ServerMessage::Ack { - job: Some(job_uuid), - })?; - tx.send(Message::Text(ack.into())).await?; + terminal_msg @ (RunnerMessage::Completed { .. } + | RunnerMessage::Failed { .. } + | RunnerMessage::Canceled { .. }) => { + // Boxed to keep the long-lived `runner_channel` future small; + // reconnect retries are rare, so the allocation is cheap. + Box::pin(handle_idle_terminal( + log, + context, + runner_id, + tx, + terminal_msg, + )) + .await?; }, RunnerMessage::Running | RunnerMessage::Heartbeat => { slog::warn!(log, "Unexpected message in Idle state, expected Ready"; "msg" => ?runner_msg); @@ -1451,20 +1406,89 @@ where } } -/// Check the runner version against the server version. +/// Handle a terminal message (Completed/Failed/Canceled) received during Idle, +/// which occurs when a runner reconnects with a pending result that was not acknowledged. +async fn handle_idle_terminal( + log: &slog::Logger, + context: &ApiContext, + runner_id: RunnerId, + tx: &mut S, + terminal_msg: RunnerMessage, +) -> Result<(), ChannelError> +where + S: futures::Sink + Unpin, +{ + let job_uuid = match terminal_msg { + RunnerMessage::Completed { + job: job_uuid, + results, + } => { + slog::info!(log, "Received Completed during Idle (retry after reconnect)"; "job_uuid" => %job_uuid); + if let Some(job) = lookup_runner_job(log, context, runner_id, job_uuid).await? { + handle_completed(log, context, &job, results).await?; + } + job_uuid + }, + RunnerMessage::Failed { + job: job_uuid, + results, + error, + } => { + slog::info!(log, "Received Failed during Idle (retry after reconnect)"; "job_uuid" => %job_uuid, "error" => &error); + if let Some(job) = lookup_runner_job(log, context, runner_id, job_uuid).await? { + handle_failed(log, context, &job, results, error).await?; + } + job_uuid + }, + RunnerMessage::Canceled { job: job_uuid } => { + slog::info!(log, "Received Canceled during Idle (retry after reconnect)"; "job_uuid" => %job_uuid); + if let Some(job) = lookup_runner_job(log, context, runner_id, job_uuid).await? { + handle_canceled(log, context, job.id).await?; + } + job_uuid + }, + // Non-terminal messages are never passed in by the caller. + RunnerMessage::Ready { .. } | RunnerMessage::Running | RunnerMessage::Heartbeat => { + return Ok(()); + }, + }; + + let ack = serde_json::to_string(&ServerMessage::Ack { + job: Some(job_uuid), + })?; + tx.send(Message::Text(ack.into())).await?; + Ok(()) +} + +/// The display version sent with canary channel updates; convergence is checksum-based. +const CANARY_UPDATE_VERSION: &str = "canary"; + +/// Check whether the runner should self-update, per its update channel. /// -/// If runner metadata is absent, skips auto-update and returns `ReadyOutcome::Ready`. -/// If the versions match, returns `ReadyOutcome::Ready` with the poll timeout. -/// If they differ, sends `ServerMessage::Update` with the download URL and +/// If runner metadata is absent (or the runner is not on Linux), skips auto-update +/// and returns `ReadyOutcome::Ready` with the poll timeout. +/// On the stable channel, the runner is up to date when its version matches the +/// server version. On the canary channel, the runner is up to date when its +/// self-reported binary checksum matches the published canary channel checksum. +/// If an update is due, sends `ServerMessage::Update` with the download URL and /// checksum, then returns `ReadyOutcome::UpdateSent`. -async fn handle_ready( +/// +/// A failure to fetch the published checksum skips the update (with a warning +/// and an adverse event counter) rather than erroring the channel: the runner +/// stays on its current version and the check retries at its next `Ready`. +async fn handle_ready( log: &slog::Logger, + runner_update: &bencher_schema::context::RunnerUpdate, + clock: &bencher_json::Clock, tx: &mut S, poll_timeout: Option, runner: Option<&bencher_json::runner::JsonRunnerMetadata>, + fetch_checksum: F, ) -> Result where S: futures::Sink + Unpin, + F: Fn(url::Url) -> Fut, + Fut: Future>, { let timeout = poll_timeout.map_or(DEFAULT_POLL_TIMEOUT, u32::from); @@ -1473,24 +1497,148 @@ where return Ok(ReadyOutcome::Ready(timeout)); }; + let channel = runner.channel.unwrap_or_default(); let server_version = bencher_json::BENCHER_API_VERSION; - slog::info!(log, "Runner ready"; "os" => %runner.os, "arch" => %runner.arch, "version" => &runner.version, "server_version" => server_version); - - if runner.version != server_version && runner.os == bencher_json::OperatingSystem::Linux { - slog::info!(log, "Runner version mismatch, sending update"; "runner" => &runner.version, "server" => server_version); - let url = runner_download_url(server_version, runner.arch)?; - let checksum = fetch_runner_checksum(&url).await?; - let update = ServerMessage::Update { - version: server_version.to_owned(), - url, - checksum, - }; - let text = serde_json::to_string(&update)?; - tx.send(Message::Text(text.into())).await?; - return Ok(ReadyOutcome::UpdateSent); + slog::info!(log, "Runner ready"; "os" => %runner.os, "arch" => %runner.arch, "version" => &runner.version, "channel" => %channel, "server_version" => server_version); + + if runner.os != bencher_json::OperatingSystem::Linux { + return Ok(ReadyOutcome::Ready(timeout)); + } + + let update = match channel { + bencher_json::UpdateChannel::Stable => { + stable_update(log, runner_update, runner, server_version, &fetch_checksum).await? + }, + bencher_json::UpdateChannel::Canary => { + canary_update(log, runner_update, clock, runner, &fetch_checksum).await? + }, + }; + + let Some(update) = update else { + return Ok(ReadyOutcome::Ready(timeout)); + }; + + #[cfg(feature = "otel")] + bencher_otel::ApiMeter::increment(bencher_otel::ApiCounter::RunnerSelfUpdateSent( + update_channel_kind(channel), + )); + + let text = serde_json::to_string(&update)?; + tx.send(Message::Text(text.into())).await?; + Ok(ReadyOutcome::UpdateSent) +} + +/// Stable channel: update when the runner version differs from the server version, +/// targeting the versioned GitHub Release. +async fn stable_update( + log: &slog::Logger, + runner_update: &bencher_schema::context::RunnerUpdate, + runner: &bencher_json::runner::JsonRunnerMetadata, + server_version: &str, + fetch_checksum: &F, +) -> Result, ChannelError> +where + F: Fn(url::Url) -> Fut, + Fut: Future>, +{ + if runner.version == server_version { + return Ok(None); } - Ok(ReadyOutcome::Ready(timeout)) + slog::info!(log, "Runner version mismatch, sending update"; "runner" => &runner.version, "server" => server_version); + let url = runner_update + .stable_url(server_version, runner.arch) + .map_err(ChannelError::UpdateUrl)?; + let checksum = match fetch_checksum(url.clone()).await { + Ok(checksum) => checksum, + Err(e) => { + slog::warn!(log, "Failed to fetch stable runner checksum, skipping update"; "url" => %url, "error" => %e); + #[cfg(feature = "otel")] + bencher_otel::ApiMeter::increment( + bencher_otel::ApiCounter::RunnerSelfUpdateCheckFailed( + bencher_otel::UpdateChannelKind::Stable, + ), + ); + return Ok(None); + }, + }; + + Ok(Some(ServerMessage::Update { + version: server_version.to_owned(), + url, + checksum, + })) +} + +/// Canary channel: update when the runner's self-reported binary checksum differs +/// from the published rolling canary channel checksum (TTL-cached per architecture). +async fn canary_update( + log: &slog::Logger, + runner_update: &bencher_schema::context::RunnerUpdate, + clock: &bencher_json::Clock, + runner: &bencher_json::runner::JsonRunnerMetadata, + fetch_checksum: &F, +) -> Result, ChannelError> +where + F: Fn(url::Url) -> Fut, + Fut: Future>, +{ + let Some(runner_checksum) = &runner.checksum else { + slog::warn!( + log, + "Canary channel runner reported no checksum, skipping auto-update" + ); + return Ok(None); + }; + + let url = runner_update + .canary_url(runner.arch) + .map_err(ChannelError::UpdateUrl)?; + + let published = if let Some(cached) = runner_update + .cached_canary_checksum(clock, runner.arch) + .await + { + cached + } else { + match fetch_checksum(url.clone()).await { + Ok(checksum) => { + runner_update + .store_canary_checksum(clock, runner.arch, checksum.clone()) + .await; + checksum + }, + Err(e) => { + slog::warn!(log, "Failed to fetch canary runner checksum, skipping update"; "url" => %url, "error" => %e); + #[cfg(feature = "otel")] + bencher_otel::ApiMeter::increment( + bencher_otel::ApiCounter::RunnerSelfUpdateCheckFailed( + bencher_otel::UpdateChannelKind::Canary, + ), + ); + return Ok(None); + }, + } + }; + + if published == *runner_checksum { + return Ok(None); + } + + slog::info!(log, "Canary channel runner checksum mismatch, sending update"; "runner" => %runner_checksum, "published" => %published); + Ok(Some(ServerMessage::Update { + version: CANARY_UPDATE_VERSION.to_owned(), + url, + checksum: published, + })) +} + +#[cfg(feature = "otel")] +fn update_channel_kind(channel: bencher_json::UpdateChannel) -> bencher_otel::UpdateChannelKind { + match channel { + bencher_json::UpdateChannel::Stable => bencher_otel::UpdateChannelKind::Stable, + bencher_json::UpdateChannel::Canary => bencher_otel::UpdateChannelKind::Canary, + } } /// Look up a job by UUID and verify it belongs to the given runner. @@ -1940,4 +2088,413 @@ mod tests { }) ); } + + // --- handle_ready update channel tests --- + + mod ready { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; + + use bencher_json::{Sha256, UpdateChannel, runner::JsonRunnerMetadata}; + use bencher_schema::context::RunnerUpdate; + use futures::{SinkExt as _, channel::mpsc}; + + use super::super::{ReadyOutcome, handle_ready}; + use crate::channel::ChannelError; + + fn test_log() -> slog::Logger { + slog::Logger::root(slog::Discard, slog::o!()) + } + + fn test_clock() -> bencher_json::Clock { + bencher_json::Clock::Custom(Arc::new(|| bencher_json::DateTime::TEST)) + } + + fn running_checksum() -> Sha256 { + "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + .parse() + .unwrap() + } + + fn published_checksum() -> Sha256 { + "b3a8e0e1f9ab1bfe3a36f231f676f78bb30a519d2b21e6c530c0eee8ebb4a5d0" + .parse() + .unwrap() + } + + fn metadata( + channel: Option, + version: &str, + checksum: Option, + ) -> JsonRunnerMetadata { + JsonRunnerMetadata { + os: bencher_json::OperatingSystem::Linux, + arch: bencher_json::Architecture::X86_64, + version: version.to_owned(), + channel, + checksum, + } + } + + /// A checksum fetcher that records requested URLs, counts calls, + /// and returns a fixed result. + struct FakeFetcher { + calls: Arc, + urls: Arc>>, + result: Result, + } + + impl FakeFetcher { + fn ok(checksum: Sha256) -> Self { + Self { + calls: Arc::new(AtomicUsize::new(0)), + urls: Arc::new(Mutex::new(Vec::new())), + result: Ok(checksum), + } + } + + fn err() -> Self { + Self { + calls: Arc::new(AtomicUsize::new(0)), + urls: Arc::new(Mutex::new(Vec::new())), + result: Err(()), + } + } + + fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn requested_urls(&self) -> Vec { + self.urls.lock().unwrap().clone() + } + + fn fetch( + &self, + ) -> impl Fn(url::Url) -> futures::future::Ready> + use<> + { + let calls = self.calls.clone(); + let urls = self.urls.clone(); + let result = self.result.clone(); + move |url| { + calls.fetch_add(1, Ordering::SeqCst); + urls.lock().unwrap().push(url); + futures::future::ready(result.clone().map_err(|()| { + ChannelError::UpdateHttpStatus(reqwest::StatusCode::NOT_FOUND) + })) + } + } + } + + /// A sink that collects sent WebSocket messages. + fn test_sink() -> ( + impl futures::Sink + Unpin, + mpsc::Receiver, + ) { + let (tx, rx) = mpsc::channel(8); + ( + tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed), + rx, + ) + } + + fn sent_update(rx: &mut mpsc::Receiver) -> ServerMessage { + let Ok(Message::Text(text)) = rx.try_recv() else { + panic!("Expected a text message to have been sent"); + }; + serde_json::from_str(&text).unwrap() + } + + use super::super::{Message, ServerMessage}; + + #[tokio::test] + async fn no_metadata_is_ready() { + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + None, + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 0); + assert!(rx.try_recv().is_err(), "no message should be sent"); + } + + #[tokio::test] + async fn non_linux_is_ready() { + let (mut tx, _rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let mut runner = metadata(Some(UpdateChannel::Stable), "0.0.0", None); + runner.os = bencher_json::OperatingSystem::Macos; + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 0); + } + + #[tokio::test] + async fn stable_version_match_is_ready() { + let (mut tx, _rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let runner = metadata(None, bencher_json::BENCHER_API_VERSION, None); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 0); + } + + #[tokio::test] + async fn absent_channel_defaults_to_stable() { + // A legacy runner sends no channel; a version mismatch must take + // the stable update path. + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let runner = metadata(None, "0.0.0", None); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::UpdateSent)); + + let ServerMessage::Update { version, .. } = sent_update(&mut rx) else { + panic!("Expected Update message"); + }; + assert_eq!(version, bencher_json::BENCHER_API_VERSION); + } + + #[tokio::test] + async fn stable_version_mismatch_sends_update() { + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let runner = metadata(Some(UpdateChannel::Stable), "0.0.0", None); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::UpdateSent)); + + let server_version = bencher_json::BENCHER_API_VERSION; + let expected_url = format!( + "https://github.com/bencherdev/bencher/releases/download/v{server_version}/runner-v{server_version}-linux-x86-64" + ); + assert_eq!( + fetcher.requested_urls(), + vec![expected_url.parse().unwrap()] + ); + + let ServerMessage::Update { + version, + url, + checksum, + } = sent_update(&mut rx) + else { + panic!("Expected Update message"); + }; + assert_eq!(version, server_version); + assert_eq!(url.as_str(), expected_url); + assert_eq!(checksum, published_checksum()); + } + + #[tokio::test] + async fn stable_checksum_fetch_failure_is_ready() { + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::err(); + let runner = metadata(Some(UpdateChannel::Stable), "0.0.0", None); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 1); + assert!(rx.try_recv().is_err(), "no message should be sent"); + } + + #[tokio::test] + async fn canary_checksum_match_is_ready() { + let (mut tx, _rx) = test_sink(); + let fetcher = FakeFetcher::ok(running_checksum()); + let runner = metadata( + Some(UpdateChannel::Canary), + "0.0.0", + Some(running_checksum()), + ); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 1); + } + + #[tokio::test] + async fn canary_checksum_mismatch_sends_update() { + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let runner = metadata( + Some(UpdateChannel::Canary), + "0.0.0", + Some(running_checksum()), + ); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::UpdateSent)); + + let expected_url = "https://github.com/bencherdev/bencher/releases/download/canary/runner-canary-linux-x86-64"; + let ServerMessage::Update { + version, + url, + checksum, + } = sent_update(&mut rx) + else { + panic!("Expected Update message"); + }; + assert_eq!(version, "canary"); + assert_eq!(url.as_str(), expected_url); + assert_eq!(checksum, published_checksum()); + } + + #[tokio::test] + async fn canary_no_runner_checksum_is_ready() { + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::ok(published_checksum()); + let runner = metadata(Some(UpdateChannel::Canary), "0.0.0", None); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 0); + assert!(rx.try_recv().is_err(), "no message should be sent"); + } + + #[tokio::test] + async fn canary_checksum_fetch_failure_is_ready() { + let (mut tx, mut rx) = test_sink(); + let fetcher = FakeFetcher::err(); + let runner = metadata( + Some(UpdateChannel::Canary), + "0.0.0", + Some(running_checksum()), + ); + let outcome = handle_ready( + &test_log(), + &RunnerUpdate::new(None), + &test_clock(), + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + assert_eq!(fetcher.call_count(), 1); + assert!(rx.try_recv().is_err(), "no message should be sent"); + } + + #[tokio::test] + async fn canary_checksum_is_cached_within_ttl() { + let (mut tx, _rx) = test_sink(); + let fetcher = FakeFetcher::ok(running_checksum()); + let runner_update = RunnerUpdate::new(None); + let clock = test_clock(); + let runner = metadata( + Some(UpdateChannel::Canary), + "0.0.0", + Some(running_checksum()), + ); + + for _ in 0..3 { + let outcome = handle_ready( + &test_log(), + &runner_update, + &clock, + &mut tx, + None, + Some(&runner), + fetcher.fetch(), + ) + .await + .unwrap(); + assert!(matches!(outcome, ReadyOutcome::Ready(_))); + } + + assert_eq!( + fetcher.call_count(), + 1, + "checksum should be fetched once and then served from cache" + ); + } + } } diff --git a/plus/api_runners/tests/common/mod.rs b/plus/api_runners/tests/common/mod.rs index 833e5d763..7b05fcdd7 100644 --- a/plus/api_runners/tests/common/mod.rs +++ b/plus/api_runners/tests/common/mod.rs @@ -31,6 +31,8 @@ pub fn runner_metadata() -> JsonRunnerMetadata { os: bencher_json::OperatingSystem::Linux, arch: bencher_json::Architecture::X86_64, version: bencher_json::BENCHER_API_VERSION.to_owned(), + channel: None, + checksum: None, } } diff --git a/plus/api_runners/tests/jobs/websocket.rs b/plus/api_runners/tests/jobs/websocket.rs index 444941465..ea8ed8b6b 100644 --- a/plus/api_runners/tests/jobs/websocket.rs +++ b/plus/api_runners/tests/jobs/websocket.rs @@ -2650,6 +2650,8 @@ async fn non_linux_version_mismatch_skips_update() { os: bencher_json::OperatingSystem::Macos, arch: bencher_json::Architecture::Aarch64, version: "0.0.0".to_owned(), + channel: None, + checksum: None, }), }; send_msg(&mut ws, &ready).await; @@ -2683,3 +2685,87 @@ async fn no_metadata_skips_update() { "Expected NoJob, got: {response:?}" ); } + +/// A stable channel Linux runner whose version matches the server version +/// is up to date: the server proceeds to job polling and returns `NoJob`. +#[tokio::test] +async fn stable_channel_version_match_no_update() { + let server = TestServer::new().await; + let admin = server.signup("Admin", "ws-stable-match@example.com").await; + + let runner = create_runner(&server, &admin.token, "Runner stable-match").await; + + let mut ws = connect_channel(&server, runner.uuid, runner.key.as_ref()).await; + let ready = RunnerMessage::Ready { + poll_timeout: Some(PollTimeout::try_from(5).expect("Invalid poll timeout")), + runner: Some(runner_metadata()), + }; + send_msg(&mut ws, &ready).await; + + let response = recv_msg(&mut ws).await; + assert!( + matches!(response, ServerMessage::NoJob), + "Expected NoJob, got: {response:?}" + ); +} + +/// A stable channel Linux runner with a stale version whose published checksum +/// cannot be fetched (unroutable update base URL) skips the update gracefully +/// instead of erroring the channel: the server proceeds and returns `NoJob`. +#[tokio::test] +async fn stable_channel_checksum_fetch_failure_proceeds() { + let base_url: url::Url = "http://127.0.0.1:1/".parse().expect("Invalid base URL"); + let server = TestServer::new_with_runner_update_base_url(base_url).await; + let admin = server.signup("Admin", "ws-stable-fetch@example.com").await; + + let runner = create_runner(&server, &admin.token, "Runner stable-fetch").await; + + let mut ws = connect_channel(&server, runner.uuid, runner.key.as_ref()).await; + let ready = RunnerMessage::Ready { + poll_timeout: Some(PollTimeout::try_from(5).expect("Invalid poll timeout")), + runner: Some(bencher_json::runner::JsonRunnerMetadata { + version: "0.0.0".to_owned(), + ..runner_metadata() + }), + }; + send_msg(&mut ws, &ready).await; + + let response = recv_msg(&mut ws).await; + assert!( + matches!(response, ServerMessage::NoJob), + "Expected NoJob, got: {response:?}" + ); +} + +/// A canary channel Linux runner whose published checksum cannot be fetched +/// (unroutable update base URL) skips the update gracefully and proceeds +/// to job polling. +#[tokio::test] +async fn canary_channel_checksum_fetch_failure_proceeds() { + let base_url: url::Url = "http://127.0.0.1:1/".parse().expect("Invalid base URL"); + let server = TestServer::new_with_runner_update_base_url(base_url).await; + let admin = server.signup("Admin", "ws-canary-fetch@example.com").await; + + let runner = create_runner(&server, &admin.token, "Runner canary-fetch").await; + + let checksum: bencher_json::Sha256 = + "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + .parse() + .expect("Invalid checksum"); + let mut ws = connect_channel(&server, runner.uuid, runner.key.as_ref()).await; + let ready = RunnerMessage::Ready { + poll_timeout: Some(PollTimeout::try_from(5).expect("Invalid poll timeout")), + runner: Some(bencher_json::runner::JsonRunnerMetadata { + channel: Some(bencher_json::UpdateChannel::Canary), + checksum: Some(checksum), + ..runner_metadata() + }), + }; + send_msg(&mut ws, &ready).await; + + let response = recv_msg(&mut ws).await; + assert!( + matches!(response, ServerMessage::NoJob), + "Expected NoJob, got: {response:?}" + ); +} diff --git a/plus/bencher_otel/src/api_meter.rs b/plus/bencher_otel/src/api_meter.rs index c91396e44..65a508ccc 100644 --- a/plus/bencher_otel/src/api_meter.rs +++ b/plus/bencher_otel/src/api_meter.rs @@ -153,6 +153,8 @@ pub enum ApiCounter { RunnerHeartbeatTimeout, RunnerJobTimeout, RunnerDisconnect, + RunnerSelfUpdateSent(UpdateChannelKind), + RunnerSelfUpdateCheckFailed(UpdateChannelKind), // Self-hosted specific metrics SelfHostedServerStartup(Uuid), @@ -184,7 +186,7 @@ impl ApiCounter { Self::UserSignup(_) => "{signup}", Self::UserLogin(_) => "{login}", - Self::UserRecaptchaFailure => "{failure}", + Self::UserRecaptchaFailure | Self::RunnerSelfUpdateCheckFailed(_) => "{failure}", Self::UserInvite | Self::UserInviteMax(_) => "{invite}", Self::UserAccept(_) => "{accept}", Self::UserConfirm => "{confirmation}", @@ -217,6 +219,7 @@ impl ApiCounter { Self::RunnerMinutesBilled | Self::RunnerMinutesBilledFailed => "{minute}", Self::RunnerHeartbeatTimeout | Self::RunnerJobTimeout => "{timeout}", Self::RunnerDisconnect => "{disconnect}", + Self::RunnerSelfUpdateSent(_) => "{update}", } } @@ -305,6 +308,8 @@ impl ApiCounter { Self::RunnerHeartbeatTimeout => "runner.heartbeat.timeout", Self::RunnerJobTimeout => "runner.job.timeout", Self::RunnerDisconnect => "runner.disconnect", + Self::RunnerSelfUpdateSent(_) => "runner.self_update.sent", + Self::RunnerSelfUpdateCheckFailed(_) => "runner.self_update.check.failed", // Self-hosted specific metrics Self::SelfHostedServerStartup(_) => "self_hosted.server.startup", @@ -423,6 +428,12 @@ impl ApiCounter { "Counts the number of jobs canceled due to exceeding job timeout" }, Self::RunnerDisconnect => "Counts the number of runner disconnections", + Self::RunnerSelfUpdateSent(_) => { + "Counts the number of runner self-update directives sent" + }, + Self::RunnerSelfUpdateCheckFailed(_) => { + "Counts the number of runner self-update checksum fetch failures" + }, // Self-hosted specific metrics Self::SelfHostedServerStartup(_) => "Counts the number of self-hosted server startups", @@ -490,6 +501,10 @@ impl ApiCounter { Self::Create(authorization_kind) | Self::CreateMax(authorization_kind) => { vec![authorization_kind.into()] }, + Self::RunnerSelfUpdateSent(update_channel_kind) + | Self::RunnerSelfUpdateCheckFailed(update_channel_kind) => { + vec![update_channel_kind.into()] + }, Self::RunnerRequestMax(interval_kind) | Self::RunUnclaimedMax(interval_kind) | Self::RunClaimedMax(interval_kind) @@ -665,6 +680,31 @@ impl JobStatusKind { const KEY: &str = "job.status"; } +#[derive(Debug, Clone, Copy)] +pub enum UpdateChannelKind { + Stable, + Canary, +} + +impl fmt::Display for UpdateChannelKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stable => write!(f, "stable"), + Self::Canary => write!(f, "canary"), + } + } +} + +impl From for opentelemetry::KeyValue { + fn from(update_channel_kind: UpdateChannelKind) -> Self { + opentelemetry::KeyValue::new(UpdateChannelKind::KEY, update_channel_kind.to_string()) + } +} + +impl UpdateChannelKind { + const KEY: &str = "update.channel"; +} + #[derive(Debug, Clone, Copy)] pub enum ProjectKeyAuthFailureReason { Invalid, diff --git a/plus/bencher_otel/src/lib.rs b/plus/bencher_otel/src/lib.rs index 9d5dc6da1..e8754afe9 100644 --- a/plus/bencher_otel/src/lib.rs +++ b/plus/bencher_otel/src/lib.rs @@ -8,5 +8,5 @@ pub use api_gauge::{ApiGauge, RunnerStateKind}; pub use api_histogram::{ApiHistogram, Priority}; pub use api_meter::{ ApiCounter, ApiMeter, AuthMethod, AuthorizationKind, IntervalKind, JobStatusKind, - OAuthProvider, ProjectKeyAuthFailureReason, UserKeyAuthFailureReason, + OAuthProvider, ProjectKeyAuthFailureReason, UpdateChannelKind, UserKeyAuthFailureReason, }; diff --git a/plus/bencher_runner/src/up/error.rs b/plus/bencher_runner/src/up/error.rs index d277320fe..ad5679091 100644 --- a/plus/bencher_runner/src/up/error.rs +++ b/plus/bencher_runner/src/up/error.rs @@ -66,6 +66,18 @@ pub enum WebSocketError { UnexpectedServerMessage(String), } +#[derive(Debug, Error)] +pub enum SelfChecksumError { + #[error("Failed to determine current binary path: {0}")] + CurrentExe(std::io::Error), + + #[error("Failed to read current binary: {0}")] + Read(std::io::Error), + + #[error("Failed to parse computed checksum: {0}")] + Parse(bencher_valid::ValidError), +} + #[derive(Debug, Error)] pub enum SelfUpdateError { #[error("Failed to determine current binary path: {0}")] diff --git a/plus/bencher_runner/src/up/job.rs b/plus/bencher_runner/src/up/job.rs index f572c4d41..2f49dae2e 100644 --- a/plus/bencher_runner/src/up/job.rs +++ b/plus/bencher_runner/src/up/job.rs @@ -541,6 +541,7 @@ mod tests { sandbox_log_level: crate::SandboxLogLevel::default(), allow_no_sandbox: false, no_auto_update: false, + update_channel: bencher_valid::UpdateChannel::default(), max_download_size: None, } } diff --git a/plus/bencher_runner/src/up/mod.rs b/plus/bencher_runner/src/up/mod.rs index 9301e625d..1bc2e1eb7 100644 --- a/plus/bencher_runner/src/up/mod.rs +++ b/plus/bencher_runner/src/up/mod.rs @@ -17,7 +17,7 @@ pub use error::UpError; use api_client::RunnerApiClient; use bencher_json::runner::{RunnerMessage, ServerMessage}; -use error::{SelfUpdateError, WebSocketError}; +use error::{SelfChecksumError, SelfUpdateError, WebSocketError}; use job::execute_job; use state_machine::{ChannelStateMachine, Effect, Input, LogLevel}; @@ -63,6 +63,8 @@ pub struct UpConfig { pub allow_no_sandbox: bool, /// Disable auto-update: do not send runner metadata to the server. pub no_auto_update: bool, + /// Update channel for automatic updates. + pub update_channel: bencher_valid::UpdateChannel, /// Maximum download size in bytes for self-update binaries. pub max_download_size: Option, } @@ -136,18 +138,28 @@ impl Up { /// function executes effects and feeds I/O results back. #[expect(clippy::print_stdout, reason = "runner CLI status output")] fn run_driver(config: &UpConfig, channel_url: &Url, key: &str) -> Result<(), UpError> { - let runner_metadata = if config.no_auto_update { - None - } else { - bencher_valid::OperatingSystem::from_host() - .ok() - .zip(bencher_valid::Architecture::from_host().ok()) - .map(|(os, arch)| bencher_json::runner::JsonRunnerMetadata { - os, - arch, - version: bencher_json::BENCHER_API_VERSION.to_owned(), - }) - }; + // Only the canary channel converges by checksum, so stable runners skip + // the full-binary read and keep their wire format checksum-free. + let checksum = + if config.no_auto_update || config.update_channel != bencher_valid::UpdateChannel::Canary { + None + } else { + match self_checksum() { + Ok(checksum) => Some(checksum), + Err(e) => { + println!(" Warning: failed to compute runner binary checksum: {e}"); + None + }, + } + }; + let runner_metadata = + build_runner_metadata(config.no_auto_update, config.update_channel, checksum); + if let Some(channel) = runner_metadata + .as_ref() + .and_then(|metadata| metadata.channel) + { + println!(" Update channel: {channel}"); + } let mut sm = ChannelStateMachine::new(config.poll_timeout_secs, runner_metadata); let mut effects: VecDeque = ChannelStateMachine::initial_effects().into_iter().collect(); @@ -545,3 +557,139 @@ fn self_update( Err(SelfUpdateError::Exec(err)) } } + +/// Build the runner metadata sent with `Ready` messages, or `None` when +/// auto-update is disabled. The stable channel is sent as absent, with no +/// checksum, so the wire format matches pre-channel runners; only the canary +/// channel reports a checksum, since only it converges by content. +fn build_runner_metadata( + no_auto_update: bool, + update_channel: bencher_valid::UpdateChannel, + checksum: Option, +) -> Option { + if no_auto_update { + return None; + } + let (channel, checksum) = match update_channel { + bencher_valid::UpdateChannel::Stable => (None, None), + channel @ bencher_valid::UpdateChannel::Canary => (Some(channel), checksum), + }; + bencher_valid::OperatingSystem::from_host() + .ok() + .zip(bencher_valid::Architecture::from_host().ok()) + .map(|(os, arch)| bencher_json::runner::JsonRunnerMetadata { + os, + arch, + version: bencher_json::BENCHER_API_VERSION.to_owned(), + channel, + checksum, + }) +} + +/// Compute the SHA-256 checksum of the currently running binary. +/// +/// Reported to the server so canary channel runners converge on the published +/// build by content rather than by version number. +fn self_checksum() -> Result { + let current_exe = std::env::current_exe().map_err(SelfChecksumError::CurrentExe)?; + file_checksum(¤t_exe) +} + +/// Compute the SHA-256 checksum of a file by streaming its contents. +fn file_checksum(path: &std::path::Path) -> Result { + use sha2::Digest as _; + use std::io::Read as _; + + let mut file = std::fs::File::open(path).map_err(SelfChecksumError::Read)?; + let mut hasher = sha2::Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf).map_err(SelfChecksumError::Read)?; + if n == 0 { + break; + } + let chunk = buf.get(..n).ok_or_else(|| { + SelfChecksumError::Read(std::io::Error::other( + "read returned byte count exceeding buffer", + )) + })?; + hasher.update(chunk); + } + hex::encode(hasher.finalize()) + .parse() + .map_err(SelfChecksumError::Parse) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_checksum_known_contents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("checksum-test"); + std::fs::write(&path, b"hello world").unwrap(); + + let checksum = file_checksum(&path).unwrap(); + // Precomputed: sha256("hello world") + let expected: bencher_valid::Sha256 = + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + .parse() + .unwrap(); + assert_eq!(checksum, expected); + } + + #[test] + fn self_checksum_matches_current_exe() { + let current_exe = std::env::current_exe().unwrap(); + assert_eq!( + self_checksum().unwrap(), + file_checksum(¤t_exe).unwrap() + ); + } + + #[test] + fn metadata_none_when_no_auto_update() { + assert!(build_runner_metadata(true, bencher_valid::UpdateChannel::Canary, None).is_none()); + } + + #[test] + fn metadata_stable_channel_is_absent() { + let metadata = + build_runner_metadata(false, bencher_valid::UpdateChannel::Stable, None).unwrap(); + assert!(metadata.channel.is_none()); + assert!(metadata.checksum.is_none()); + assert_eq!(metadata.version, bencher_json::BENCHER_API_VERSION); + } + + #[test] + fn metadata_stable_channel_drops_checksum() { + // Stable runners must stay checksum-free on the wire, even if a + // checksum was computed. + let checksum: bencher_valid::Sha256 = + "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + .parse() + .unwrap(); + let metadata = + build_runner_metadata(false, bencher_valid::UpdateChannel::Stable, Some(checksum)) + .unwrap(); + assert!(metadata.channel.is_none()); + assert!(metadata.checksum.is_none()); + } + + #[test] + fn metadata_canary_channel_with_checksum() { + let checksum: bencher_valid::Sha256 = + "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + .parse() + .unwrap(); + let metadata = build_runner_metadata( + false, + bencher_valid::UpdateChannel::Canary, + Some(checksum.clone()), + ) + .unwrap(); + assert_eq!(metadata.channel, Some(bencher_valid::UpdateChannel::Canary)); + assert_eq!(metadata.checksum, Some(checksum)); + } +} diff --git a/plus/bencher_runner/src/up/state_machine.rs b/plus/bencher_runner/src/up/state_machine.rs index 0d3e4109f..93be7ee8d 100644 --- a/plus/bencher_runner/src/up/state_machine.rs +++ b/plus/bencher_runner/src/up/state_machine.rs @@ -647,6 +647,8 @@ mod tests { os: OperatingSystem::Linux, arch: Architecture::X86_64, version: bencher_json::BENCHER_API_VERSION.to_owned(), + channel: None, + checksum: None, }), ) } @@ -1244,6 +1246,8 @@ mod tests { os: OperatingSystem::Linux, arch: Architecture::X86_64, version: bencher_json::BENCHER_API_VERSION.to_owned(), + channel: None, + checksum: None, }), ); let effects = sm.step(Input::Connected); diff --git a/services/api/openapi.json b/services/api/openapi.json index eeca82ae3..61d601c2e 100644 --- a/services/api/openapi.json +++ b/services/api/openapi.json @@ -16279,6 +16279,15 @@ "$ref": "#/components/schemas/GracePeriod" } ] + }, + "update_base_url": { + "nullable": true, + "description": "Base URL for runner self-update downloads. Defaults to the Bencher GitHub Releases download URL.", + "allOf": [ + { + "$ref": "#/components/schemas/Url" + } + ] } } }, diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/de/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/de/run.mdx index 308d1b772..fcdd95ad5 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/de/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/de/run.mdx @@ -25,6 +25,9 @@ Das `runner`-Binary öffnet eine einzige [WebSocket-Verbindung][runner protocol] fragt Jobs ab, die zu seinen Specs passen, führt jeden aus und meldet die Ergebnisse zurück. Die Verbindung bleibt über Jobs hinweg offen, und das Binary [aktualisiert sich selbst][runner up] zwischen Jobs, wenn eine neue Version verfügbar ist. +Standardmäßig folgt der Runner dem `stable` [Update-Kanal][update channel] +und aktualisiert nur auf versionierte Releases. +Der `canary`-Kanal folgt stattdessen dem rollierenden Canary-Build von Bencher Cloud. Standardmäßig lehnt ein Runner jeden Job ab, dessen Spec keine [Sandbox][sandbox] hat. Um einem Runner zu erlauben, Jobs ohne Sandbox direkt auf dem Host auszuführen, @@ -46,6 +49,7 @@ Die [`runner` CLI][runner reference]-Referenz beschreibt jede Option, und die [Runner Protocol][runner protocol]-Referenz beschreibt die mit dem Server ausgetauschten Nachrichten. [runner up]: /de/docs/reference/runner/#runner-up +[update channel]: /de/docs/reference/runner/#--update-channel-update_channel [runner reference]: /de/docs/reference/runner/ [runner protocol]: /de/docs/reference/runner-protocol/ [sandbox]: /de/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/en/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/en/run.mdx index 2f252c73a..862400697 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/en/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/en/run.mdx @@ -25,6 +25,9 @@ The `runner` binary opens a single [WebSocket connection][runner protocol] to th polls for Jobs matching its Specs, executes each one, and reports the results back. The connection stays open across Jobs, and the binary [updates itself][runner up] between Jobs when a new version is available. +By default the Runner follows the `stable` [update channel][update channel], +updating only to versioned releases. +The `canary` channel instead tracks the rolling canary build deployed to Bencher Cloud. By default a Runner rejects any Job whose Spec has no [Sandbox][sandbox]. To let a Runner execute non-sandboxed Jobs directly on the host, @@ -46,6 +49,7 @@ See the [`runner` CLI][runner reference] reference for every option, and the [Runner Protocol][runner protocol] reference for the messages exchanged with the server. [runner up]: /docs/reference/runner/#runner-up +[update channel]: /docs/reference/runner/#--update-channel-update_channel [runner reference]: /docs/reference/runner/ [runner protocol]: /docs/reference/runner-protocol/ [sandbox]: /docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/es/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/es/run.mdx index 639a1b692..02ac1e068 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/es/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/es/run.mdx @@ -25,6 +25,9 @@ El binario `runner` abre una única [conexión WebSocket][runner protocol] al se sondea Jobs que coincidan con sus Specs, ejecuta cada uno, y reporta los resultados de vuelta. La conexión permanece abierta entre Jobs, y el binario [se actualiza a sí mismo][runner up] entre Jobs cuando hay una nueva versión disponible. +Por defecto, el Runner sigue el [canal de actualización][update channel] `stable` +y solo se actualiza a releases versionadas. +El canal `canary` sigue en cambio la compilación canary continua desplegada en Bencher Cloud. Por defecto, un Runner rechaza cualquier Job cuyo Spec no tenga [Sandbox][sandbox]. Para permitir que un Runner ejecute Jobs sin sandbox directamente en el host, @@ -46,6 +49,7 @@ Consulta la referencia de la [CLI `runner`][runner reference] para conocer todas y la referencia del [Protocolo Runner][runner protocol] para los mensajes intercambiados con el servidor. [runner up]: /es/docs/reference/runner/#runner-up +[update channel]: /es/docs/reference/runner/#--update-channel-update_channel [runner reference]: /es/docs/reference/runner/ [runner protocol]: /es/docs/reference/runner-protocol/ [sandbox]: /es/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/fr/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/fr/run.mdx index e433dc0af..a73295cba 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/fr/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/fr/run.mdx @@ -25,6 +25,9 @@ Le binaire `runner` ouvre une unique [connexion WebSocket][runner protocol] vers interroge les Jobs correspondant à ses Specs, exécute chacun d'eux, et renvoie les résultats. La connexion reste ouverte d'un Job à l'autre, et le binaire [se met à jour lui-même][runner up] entre les Jobs lorsqu'une nouvelle version est disponible. +Par défaut, le Runner suit le [canal de mise à jour][update channel] `stable` +et ne se met à jour que vers les versions publiées. +Le canal `canary` suit quant à lui le build canary continu déployé sur Bencher Cloud. Par défaut, un Runner rejette tout Job dont le Spec n'a pas de [Sandbox][sandbox]. Pour permettre à un Runner d'exécuter directement des Jobs sans sandbox sur l'hôte, @@ -46,6 +49,7 @@ Consultez la référence de la [CLI `runner`][runner reference] pour chaque opti et la référence du [Protocole Runner][runner protocol] pour les messages échangés avec le serveur. [runner up]: /fr/docs/reference/runner/#runner-up +[update channel]: /fr/docs/reference/runner/#--update-channel-update_channel [runner reference]: /fr/docs/reference/runner/ [runner protocol]: /fr/docs/reference/runner-protocol/ [sandbox]: /fr/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/ja/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/ja/run.mdx index e59cdd6e7..9b949a4e1 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/ja/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/ja/run.mdx @@ -25,6 +25,9 @@ runner up 自身の Spec に一致する Job をポーリングし、それぞれを実行して結果を報告します。 接続は複数の Job にわたって開いたままになり、 新しいバージョンが利用可能になると、バイナリは Job の合間に [自身を更新します][runner up]。 +デフォルトでは、Runner は `stable` [更新チャネル][update channel]に従い、 +バージョン付きリリースへのみ更新します。 +`canary` チャネルは、Bencher Cloud にデプロイされるローリング canary ビルドを追跡します。 デフォルトでは、Runner は [Sandbox][sandbox] のない Spec の Job をすべて拒否します。 Runner にサンドボックスなしの Job をホスト上で直接実行させるには、 @@ -46,6 +49,7 @@ Runner にサンドボックスなしの Job をホスト上で直接実行さ サーバーとやり取りされるメッセージについては [Runner プロトコル][runner protocol] リファレンスを参照してください。 [runner up]: /ja/docs/reference/runner/#runner-up +[update channel]: /ja/docs/reference/runner/#--update-channel-update_channel [runner reference]: /ja/docs/reference/runner/ [runner protocol]: /ja/docs/reference/runner-protocol/ [sandbox]: /ja/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/ko/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/ko/run.mdx index e2127367f..a43310e76 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/ko/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/ko/run.mdx @@ -25,6 +25,9 @@ runner up 자신의 Spec과 일치하는 Job을 폴링하고, 각 Job을 실행한 후, 결과를 다시 보고합니다. 연결은 여러 Job에 걸쳐 열린 상태로 유지되며, 새 버전이 있을 때 바이너리는 Job 사이에 [스스로 업데이트][runner up]합니다. +기본적으로 Runner는 `stable` [업데이트 채널][update channel]을 따르며, +버전이 지정된 릴리스로만 업데이트합니다. +`canary` 채널은 Bencher Cloud에 배포되는 롤링 canary 빌드를 추적합니다. 기본적으로 Runner는 [Sandbox][sandbox]가 없는 Spec의 Job을 거부합니다. Runner가 샌드박스 없는 Job을 호스트에서 직접 실행하도록 하려면, @@ -46,6 +49,7 @@ Runner가 샌드박스 없는 Job을 호스트에서 직접 실행하도록 하 서버와 주고받는 메시지는 [Runner Protocol][runner protocol] 참조를 확인하세요. [runner up]: /ko/docs/reference/runner/#runner-up +[update channel]: /ko/docs/reference/runner/#--update-channel-update_channel [runner reference]: /ko/docs/reference/runner/ [runner protocol]: /ko/docs/reference/runner-protocol/ [sandbox]: /ko/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/pt/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/pt/run.mdx index c41904749..8c32f8924 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/pt/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/pt/run.mdx @@ -25,6 +25,9 @@ O binário `runner` abre uma única [conexão WebSocket][runner protocol] ao ser faz polling por Jobs que correspondam às suas Specs, executa cada um e reporta os resultados de volta. A conexão permanece aberta entre os Jobs, e o binário [se atualiza][runner up] entre os Jobs quando uma nova versão está disponível. +Por padrão, o Runner segue o [canal de atualização][update channel] `stable` +e só atualiza para releases versionadas. +O canal `canary`, por sua vez, acompanha o build canary contínuo implantado no Bencher Cloud. Por padrão, um Runner rejeita qualquer Job cuja Spec não tenha [Sandbox][sandbox]. Para permitir que um Runner execute Jobs sem sandbox diretamente no host, @@ -46,6 +49,7 @@ Consulte a referência da [CLI `runner`][runner reference] para todas as opçõe e a referência do [Runner Protocol][runner protocol] para as mensagens trocadas com o servidor. [runner up]: /pt/docs/reference/runner/#runner-up +[update channel]: /pt/docs/reference/runner/#--update-channel-update_channel [runner reference]: /pt/docs/reference/runner/ [runner protocol]: /pt/docs/reference/runner-protocol/ [sandbox]: /pt/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/ru/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/ru/run.mdx index 7ddc54ce2..9f5b27809 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/ru/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/ru/run.mdx @@ -25,6 +25,9 @@ runner up опрашивает на наличие Job, соответствующих его Spec, выполняет каждую и сообщает результаты обратно. Соединение остаётся открытым между Job, и бинарный файл [обновляет себя][runner up] между Job, когда доступна новая версия. +По умолчанию Runner следует [каналу обновлений][update channel] `stable` +и обновляется только до версионированных релизов. +Канал `canary` вместо этого отслеживает скользящую canary-сборку, развёрнутую в Bencher Cloud. По умолчанию Runner отклоняет любую Job, чей Spec не имеет [Sandbox][sandbox]. Чтобы разрешить Runner выполнять не изолированные Job напрямую на хосте, @@ -46,6 +49,7 @@ runner up а сообщения, которыми обмениваются с сервером, — в справочнике по [Runner Protocol][runner protocol]. [runner up]: /ru/docs/reference/runner/#runner-up +[update channel]: /ru/docs/reference/runner/#--update-channel-update_channel [runner reference]: /ru/docs/reference/runner/ [runner protocol]: /ru/docs/reference/runner-protocol/ [sandbox]: /ru/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-explanation/self-hosted-runners/zh/run.mdx b/services/console/src/chunks/docs-explanation/self-hosted-runners/zh/run.mdx index 75fbaf721..067b89b3d 100644 --- a/services/console/src/chunks/docs-explanation/self-hosted-runners/zh/run.mdx +++ b/services/console/src/chunks/docs-explanation/self-hosted-runners/zh/run.mdx @@ -25,6 +25,9 @@ runner up 轮询与其 Spec 匹配的 Job,逐个执行,并将结果报告回去。 该连接在多个 Job 之间保持打开, 并且当有新版本可用时,二进制文件会在 Job 之间[自我更新][runner up]。 +默认情况下,Runner 遵循 `stable` [更新通道][update channel], +仅更新到版本化的发布版本。 +`canary` 通道则跟踪部署到 Bencher Cloud 的滚动 canary 构建。 默认情况下,Runner 会拒绝任何其 Spec 没有 [Sandbox][sandbox] 的 Job。 要让 Runner 直接在主机上执行非沙箱化的 Job, @@ -46,6 +49,7 @@ runner up 有关与服务器交换的消息,请参阅 [Runner 协议][runner protocol] 参考。 [runner up]: /zh/docs/reference/runner/#runner-up +[update channel]: /zh/docs/reference/runner/#--update-channel-update_channel [runner reference]: /zh/docs/reference/runner/ [runner protocol]: /zh/docs/reference/runner-protocol/ [sandbox]: /zh/docs/explanation/bare-metal/#sandbox diff --git a/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx b/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx index 8d68d2146..ceae76cc7 100644 --- a/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx +++ b/services/console/src/chunks/docs-reference/changelog/en/changelog.mdx @@ -1,3 +1,7 @@ +## Pending `v0.6.9` +- Add runner update channels via `runner up --update-channel ` (default: `stable`); the `canary` channel tracks a rolling prerelease that is rebuilt whenever the runner changes on a Bencher Cloud deploy, so runner changes are field-tested on Bencher Cloud before a versioned release; `canary` runners report their binary checksum and converge on the published build by content rather than by version +- Runner self-update checksum fetch failures no longer drop the WebSocket channel into a reconnect loop; the server now logs the failure, skips the update offer, and retries at the runner's next `ready` poll + ## `v0.6.8` - **BREAKING CHANGE** Change the default self-hosted API server port from `61016` to the newly [IANA-registered](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=6610) `6610`. Deployments relying on the default now serve the API on `6610`. Update clients, reverse proxies, and `--host`/`BENCHER_HOST` references (Docker Compose, devcontainer, and docs are updated to match). To stay on `61016`, set `server.bind_address` to `0.0.0.0:61016` (or run `bencher up --api-port 61016`). - Mark the remaining user API token REST endpoints (list, view, update, and revoke) as deprecated in the OpenAPI spec and API docs; they continue to work for existing tokens diff --git a/services/console/src/chunks/docs-reference/runner-protocol/de/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/de/runner-messages.mdx index 467e56f6e..452f2812f 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/de/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/de/runner-messages.mdx @@ -4,7 +4,7 @@ Nachrichten, die vom Runner an den Server gesendet werden. | Event | Beschreibung | Payload | | ----------- | -------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | Der Runner ist im Leerlauf und fordert einen Job an | Optionales `poll_timeout` (1-900s) und `runner`-Metadaten (`os`, `arch`, `version`) | +| `ready` | Der Runner ist im Leerlauf und fordert einen Job an | Optionales `poll_timeout` (1-900s) und `runner`-Metadaten (`os`, `arch`, `version`, optionaler Update-`channel` und optionale Binary-`checksum`) | | `running` | Job-Setup ist abgeschlossen und der Benchmark startet | Keine | | `heartbeat` | Periodisches Lebenszeichen (etwa einmal pro Sekunde) | Keine | | `completed` | Der Benchmark wurde erfolgreich abgeschlossen | `job` (Job-UUID) und `results` (Ausgabe pro Iteration) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/de/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/de/server-messages.mdx index 94f44b0fb..d8bc2247f 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/de/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/de/server-messages.mdx @@ -13,3 +13,8 @@ Nachrichten, die vom Server an den Runner gesendet werden. Das OCI-Pull-Token in einer `job`-Nachricht wird beim Beanspruchen des Jobs generiert und niemals gespeichert. Es ist auf das einzelne Projekt beschränkt, zu dem der Job gehört, ist nur zum Herunterladen (Pull) gedacht und ist kurzlebig, sodass ein kompromittierter Runner nur Images für das Projekt des von ihm beanspruchten Jobs herunterladen kann. + +Auf dem `stable`-Update-Kanal wird ein `update` gesendet, wenn die Runner-Version von der Server-Version abweicht. +Auf dem `canary`-Update-Kanal ist `version` gleich `canary`, +und ein `update` wird gesendet, wenn die vom Runner selbst gemeldete Prüfsumme des Binaries +vom veröffentlichten rollierenden Canary-Build abweicht. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/en/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/en/runner-messages.mdx index 372afaa94..930e7ede3 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/en/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/en/runner-messages.mdx @@ -4,7 +4,7 @@ Messages sent from the Runner to the server. | Event | Description | Payload | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | The Runner is idle and requesting a Job | Optional `poll_timeout` (1-900s) and `runner` metadata (`os`, `arch`, `version`) | +| `ready` | The Runner is idle and requesting a Job | Optional `poll_timeout` (1-900s) and `runner` metadata (`os`, `arch`, `version`, optional update `channel`, and optional binary `checksum`) | | `running` | Job setup is complete and the benchmark is starting | None | | `heartbeat` | Periodic liveness signal (about once per second) | None | | `completed` | The benchmark completed successfully | `job` (Job UUID) and `results` (per-iteration output) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/en/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/en/server-messages.mdx index 63e72fee8..ed6107894 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/en/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/en/server-messages.mdx @@ -13,3 +13,8 @@ Messages sent from the server to the Runner. The OCI pull token in a `job` message is generated when the Job is claimed and is never stored. It is scoped to the single project the Job belongs to, is pull only, and is short-lived, so a compromised Runner can only pull Images for the project of the Job it claimed. + +On the `stable` update channel, an `update` is sent when the Runner version differs from the server version. +On the `canary` update channel, `version` is `canary` +and an `update` is sent when the Runner's self-reported binary checksum +differs from the published rolling canary build. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/es/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/es/runner-messages.mdx index 79e0b1c61..acc4292f5 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/es/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/es/runner-messages.mdx @@ -4,7 +4,7 @@ Mensajes enviados desde el Runner al servidor. | Event | Descripción | Payload | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | El Runner está inactivo y solicitando un Job | `poll_timeout` opcional (1-900s) y metadatos del `runner` (`os`, `arch`, `version`) | +| `ready` | El Runner está inactivo y solicitando un Job | `poll_timeout` opcional (1-900s) y metadatos del `runner` (`os`, `arch`, `version`, `channel` de actualización opcional, y `checksum` del binario opcional) | | `running` | La configuración del Job está completa y el benchmark está comenzando | Ninguno | | `heartbeat` | Señal periódica de vida (aproximadamente una vez por segundo) | Ninguno | | `completed` | El benchmark se completó correctamente | `job` (UUID del Job) y `results` (salida por iteración) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/es/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/es/server-messages.mdx index 7f26f0aaa..1ac7ecaaa 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/es/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/es/server-messages.mdx @@ -13,3 +13,8 @@ Mensajes enviados desde el servidor al Runner. El token de descarga OCI en un mensaje `job` se genera cuando se reclama el Job y nunca se almacena. Está limitado al único proyecto al que pertenece el Job, es solo de descarga, y es de corta duración, de modo que un Runner comprometido solo puede descargar Images del proyecto del Job que reclamó. + +En el canal de actualización `stable`, se envía un `update` cuando la versión del Runner difiere de la versión del servidor. +En el canal de actualización `canary`, `version` es `canary` +y se envía un `update` cuando la suma de comprobación del binario reportada por el propio Runner +difiere de la compilación canary continua publicada. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/fr/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/fr/runner-messages.mdx index 3b04b3318..d0c528d04 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/fr/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/fr/runner-messages.mdx @@ -4,7 +4,7 @@ Messages envoyés du Runner vers le serveur. | Event | Description | Charge utile | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | Le Runner est inactif et demande un Job | `poll_timeout` optionnel (1-900 s) et métadonnées `runner` (`os`, `arch`, `version`) | +| `ready` | Le Runner est inactif et demande un Job | `poll_timeout` optionnel (1-900 s) et métadonnées `runner` (`os`, `arch`, `version`, `channel` de mise à jour optionnel et `checksum` du binaire optionnel) | | `running` | La préparation du Job est terminée et le benchmark démarre | Aucune | | `heartbeat` | Signal de vie périodique (environ une fois par seconde) | Aucune | | `completed` | Le benchmark s'est terminé avec succès | `job` (UUID du Job) et `results` (sortie par itération) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/fr/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/fr/server-messages.mdx index 264f9b33f..b9f330cdf 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/fr/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/fr/server-messages.mdx @@ -13,3 +13,8 @@ Messages envoyés du serveur vers le Runner. Le jeton de récupération OCI dans un message `job` est généré lorsque le Job est réclamé et n'est jamais stocké. Il est limité au seul projet auquel le Job appartient, est en lecture seule (pull only), et a une courte durée de vie, de sorte qu'un Runner compromis ne peut récupérer que les Images du projet du Job qu'il a réclamé. + +Sur le canal de mise à jour `stable`, un `update` est envoyé lorsque la version du Runner diffère de la version du serveur. +Sur le canal de mise à jour `canary`, `version` vaut `canary` +et un `update` est envoyé lorsque le checksum du binaire rapporté par le Runner lui-même +diffère du build canary continu publié. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/ja/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/ja/runner-messages.mdx index 8e52dc3d4..acee87e1d 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/ja/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/ja/runner-messages.mdx @@ -4,7 +4,7 @@ Runner からサーバーに送信されるメッセージ。 | イベント | 説明 | ペイロード | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | Runner はアイドル状態で Job を要求している | オプションの `poll_timeout` (1〜900 秒) と `runner` メタデータ (`os`、`arch`、`version`) | +| `ready` | Runner はアイドル状態で Job を要求している | オプションの `poll_timeout` (1〜900 秒) と `runner` メタデータ (`os`、`arch`、`version`、オプションの更新 `channel`、オプションのバイナリ `checksum`) | | `running` | Job のセットアップが完了し、ベンチマークが開始する | なし | | `heartbeat` | 定期的な生存シグナル (約 1 秒に 1 回) | なし | | `completed` | ベンチマークが正常に完了した | `job` (Job UUID) と `results` (反復ごとの出力) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/ja/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/ja/server-messages.mdx index 4c7274801..8124d4741 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/ja/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/ja/server-messages.mdx @@ -13,3 +13,8 @@ `job` メッセージ内の OCI プルトークンは、Job が要求された際に生成され、保存されることはありません。 これは Job が属する単一のプロジェクトにスコープが限定され、プル専用かつ短命であるため、 侵害された Runner は、自身が要求した Job のプロジェクトの Image しかプルできません。 + +`stable` 更新チャネルでは、Runner のバージョンがサーバーのバージョンと異なる場合に `update` が送信されます。 +`canary` 更新チャネルでは、`version` は `canary` となり、 +Runner が自己申告したバイナリのチェックサムが +公開されているローリング canary ビルドと異なる場合に `update` が送信されます。 diff --git a/services/console/src/chunks/docs-reference/runner-protocol/ko/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/ko/runner-messages.mdx index 0d8972a27..21739fc25 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/ko/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/ko/runner-messages.mdx @@ -4,7 +4,7 @@ Runner가 서버로 보내는 메시지입니다. | Event | 설명 | 페이로드 | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | Runner가 유휴 상태이며 Job을 요청합니다 | 선택적 `poll_timeout`(1-900초)과 `runner` 메타데이터(`os`, `arch`, `version`) | +| `ready` | Runner가 유휴 상태이며 Job을 요청합니다 | 선택적 `poll_timeout`(1-900초)과 `runner` 메타데이터(`os`, `arch`, `version`, 선택적 업데이트 `channel`, 그리고 선택적 바이너리 `checksum`) | | `running` | Job 설정이 완료되고 벤치마크가 시작됩니다 | 없음 | | `heartbeat` | 주기적인 활성 신호(약 1초에 한 번) | 없음 | | `completed` | 벤치마크가 성공적으로 완료되었습니다 | `job`(Job UUID)과 `results`(반복별 출력) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/ko/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/ko/server-messages.mdx index c1b6a7533..0b29556c8 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/ko/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/ko/server-messages.mdx @@ -13,3 +13,8 @@ `job` 메시지의 OCI 풀 토큰은 Job이 청구될 때 생성되며 저장되지 않습니다. 이 토큰은 Job이 속한 단일 프로젝트로 범위가 한정되고, 풀 전용이며, 짧은 수명을 가지므로, 손상된 Runner는 자신이 청구한 Job의 프로젝트에 대한 Image만 풀할 수 있습니다. + +`stable` 업데이트 채널에서는 Runner 버전이 서버 버전과 다를 때 `update`가 전송됩니다. +`canary` 업데이트 채널에서는 `version`이 `canary`이며, +Runner가 스스로 보고한 바이너리 체크섬이 +공개된 롤링 canary 빌드와 다를 때 `update`가 전송됩니다. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/pt/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/pt/runner-messages.mdx index 55dbef35a..65b729bb3 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/pt/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/pt/runner-messages.mdx @@ -4,7 +4,7 @@ Mensagens enviadas do Runner para o servidor. | Event | Descrição | Payload | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | O Runner está ocioso e solicitando um Job | `poll_timeout` opcional (1-900s) e metadados do `runner` (`os`, `arch`, `version`) | +| `ready` | O Runner está ocioso e solicitando um Job | `poll_timeout` opcional (1-900s) e metadados do `runner` (`os`, `arch`, `version`, `channel` de atualização opcional e `checksum` do binário opcional) | | `running` | A configuração do Job está concluída e o benchmark está iniciando | Nenhum | | `heartbeat` | Sinal periódico de atividade (cerca de uma vez por segundo) | Nenhum | | `completed` | O benchmark foi concluído com sucesso | `job` (UUID do Job) e `results` (saída por iteração) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/pt/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/pt/server-messages.mdx index 746a9b5c9..5d7abe69a 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/pt/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/pt/server-messages.mdx @@ -13,3 +13,8 @@ Mensagens enviadas do servidor para o Runner. O token de pull OCI em uma mensagem `job` é gerado quando o Job é reivindicado e nunca é armazenado. Ele é restrito ao único projeto ao qual o Job pertence, é somente para pull e é de curta duração, de modo que um Runner comprometido só pode baixar Images do projeto do Job que reivindicou. + +No canal de atualização `stable`, um `update` é enviado quando a versão do Runner difere da versão do servidor. +No canal de atualização `canary`, `version` é `canary` +e um `update` é enviado quando o checksum do binário reportado pelo próprio Runner +difere do build canary contínuo publicado. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/ru/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/ru/runner-messages.mdx index 8f489d675..b29fab2ac 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/ru/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/ru/runner-messages.mdx @@ -4,7 +4,7 @@ | Event | Описание | Полезная нагрузка | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | Runner простаивает и запрашивает Job | Необязательные `poll_timeout` (1-900 с) и метаданные `runner` (`os`, `arch`, `version`) | +| `ready` | Runner простаивает и запрашивает Job | Необязательные `poll_timeout` (1-900 с) и метаданные `runner` (`os`, `arch`, `version`, необязательный `channel` обновления и необязательная `checksum` бинарного файла) | | `running` | Настройка Job завершена, бенчмарк запускается | Нет | | `heartbeat` | Периодический сигнал жизнеспособности (примерно раз в секунду) | Нет | | `completed` | Бенчмарк успешно завершён | `job` (UUID Job) и `results` (вывод по каждой итерации) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/ru/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/ru/server-messages.mdx index 7e91c9a79..af32aaa5f 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/ru/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/ru/server-messages.mdx @@ -13,3 +13,8 @@ OCI pull-токен в сообщении `job` генерируется при взятии Job и никогда не хранится. Он ограничен единственным проектом, которому принадлежит Job, доступен только для загрузки и краткоживущий, поэтому скомпрометированный Runner может загружать Image только для проекта той Job, которую он взял. + +На канале обновлений `stable` сообщение `update` отправляется, когда версия Runner отличается от версии сервера. +На канале обновлений `canary` поле `version` равно `canary`, +а сообщение `update` отправляется, когда сообщённая самим Runner контрольная сумма бинарного файла +отличается от опубликованной скользящей canary-сборки. diff --git a/services/console/src/chunks/docs-reference/runner-protocol/zh/runner-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/zh/runner-messages.mdx index 5261b138d..ec34c6e0e 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/zh/runner-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/zh/runner-messages.mdx @@ -4,7 +4,7 @@ | Event | 描述 | 负载 | | ----------- | ------------------------------------------------- | ----------------------------------------------------------------------- | -| `ready` | Runner 空闲并请求一个 Job | 可选的 `poll_timeout`(1-900 秒)和 `runner` 元数据(`os`、`arch`、`version`) | +| `ready` | Runner 空闲并请求一个 Job | 可选的 `poll_timeout`(1-900 秒)和 `runner` 元数据(`os`、`arch`、`version`、可选的更新通道 `channel` 和可选的二进制校验和 `checksum`) | | `running` | Job 设置完成,基准测试正在启动 | 无 | | `heartbeat` | 周期性存活信号(约每秒一次) | 无 | | `completed` | 基准测试成功完成 | `job`(Job UUID)和 `results`(每次迭代的输出) | diff --git a/services/console/src/chunks/docs-reference/runner-protocol/zh/server-messages.mdx b/services/console/src/chunks/docs-reference/runner-protocol/zh/server-messages.mdx index 1ca84d715..279cc23b1 100644 --- a/services/console/src/chunks/docs-reference/runner-protocol/zh/server-messages.mdx +++ b/services/console/src/chunks/docs-reference/runner-protocol/zh/server-messages.mdx @@ -13,3 +13,8 @@ `job` 消息中的 OCI 拉取令牌在 Job 被认领时生成,且从不存储。 它的作用域限定在 Job 所属的单个 project,仅可拉取,且短期有效, 因此一个被攻陷的 Runner 只能拉取其所认领 Job 对应 project 的 Image。 + +在 `stable` 更新通道上,当 Runner 的版本与服务器版本不同时,会发送 `update`。 +在 `canary` 更新通道上,`version` 为 `canary`, +并且当 Runner 自行报告的二进制校验和 +与已发布的滚动 canary 构建不同时,会发送 `update`。 diff --git a/services/console/src/chunks/docs-reference/runner/de/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/de/runner-up.mdx index 5b2946406..a27169f34 100644 --- a/services/console/src/chunks/docs-reference/runner/de/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/de/runner-up.mdx @@ -53,6 +53,16 @@ Deaktiviert automatische Updates vom Server. Standardmäßig aktualisiert sich der Runner zwischen Jobs selbst, wenn der Server eine neue Version anbietet. Kann auch mit der Umgebungsvariable `BENCHER_NO_AUTO_UPDATE` gesetzt werden. +### `--update-channel ` + +Der Update-Kanal für automatische Updates, entweder `stable` oder `canary`. +Standardmäßig wird `stable` verwendet. +Der `stable`-Kanal aktualisiert auf versionierte Releases. +Der `canary`-Kanal folgt dem rollierenden Canary-Build, +der neu gebaut wird, wann immer sich der Runner bei einem Bencher Cloud-Deployment ändert. +Kann auch mit der Umgebungsvariable `BENCHER_UPDATE_CHANNEL` gesetzt werden. +Steht im Konflikt mit `--no-auto-update`. + ### `--max-download-size ` Die maximale Download-Größe in Bytes für Self-Update-Binaries. diff --git a/services/console/src/chunks/docs-reference/runner/en/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/en/runner-up.mdx index e77a677c7..21455118c 100644 --- a/services/console/src/chunks/docs-reference/runner/en/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/en/runner-up.mdx @@ -53,6 +53,16 @@ Disable automatic updates from the server. By default, the runner updates itself between Jobs when the server offers a new version. Can also be set with the `BENCHER_NO_AUTO_UPDATE` environment variable. +### `--update-channel ` + +The update channel for automatic updates, either `stable` or `canary`. +By default, `stable` is used. +The `stable` channel updates to versioned releases. +The `canary` channel tracks the rolling canary build, +which is rebuilt whenever the runner changes on a Bencher Cloud deploy. +Can also be set with the `BENCHER_UPDATE_CHANNEL` environment variable. +Conflicts with `--no-auto-update`. + ### `--max-download-size ` The maximum download size in bytes for self-update binaries. diff --git a/services/console/src/chunks/docs-reference/runner/es/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/es/runner-up.mdx index c363247db..503e46964 100644 --- a/services/console/src/chunks/docs-reference/runner/es/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/es/runner-up.mdx @@ -53,6 +53,16 @@ Desactiva las actualizaciones automáticas desde el servidor. Por defecto, el runner se actualiza a sí mismo entre Jobs cuando el servidor ofrece una nueva versión. También se puede establecer con la variable de entorno `BENCHER_NO_AUTO_UPDATE`. +### `--update-channel ` + +El canal de actualización para las actualizaciones automáticas, ya sea `stable` o `canary`. +Por defecto, se usa `stable`. +El canal `stable` actualiza a releases versionadas. +El canal `canary` sigue la compilación canary continua, +que se reconstruye cada vez que el runner cambia en un despliegue de Bencher Cloud. +También se puede establecer con la variable de entorno `BENCHER_UPDATE_CHANNEL`. +Entra en conflicto con `--no-auto-update`. + ### `--max-download-size ` El tamaño máximo de descarga en bytes para los binarios de auto-actualización. diff --git a/services/console/src/chunks/docs-reference/runner/fr/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/fr/runner-up.mdx index e7eccabc4..bd5e6b4e3 100644 --- a/services/console/src/chunks/docs-reference/runner/fr/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/fr/runner-up.mdx @@ -53,6 +53,16 @@ Désactive les mises à jour automatiques depuis le serveur. Par défaut, le runner se met à jour lui-même entre les Jobs lorsque le serveur propose une nouvelle version. Peut aussi être défini avec la variable d'environnement `BENCHER_NO_AUTO_UPDATE`. +### `--update-channel ` + +Le canal de mise à jour pour les mises à jour automatiques, soit `stable`, soit `canary`. +Par défaut, `stable` est utilisé. +Le canal `stable` met à jour vers les versions publiées. +Le canal `canary` suit le build canary continu, +qui est reconstruit chaque fois que le runner change lors d'un déploiement Bencher Cloud. +Peut aussi être défini avec la variable d'environnement `BENCHER_UPDATE_CHANNEL`. +En conflit avec `--no-auto-update`. + ### `--max-download-size ` La taille de téléchargement maximale en octets pour les binaires de mise à jour automatique. diff --git a/services/console/src/chunks/docs-reference/runner/ja/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/ja/runner-up.mdx index e07c55958..37619064b 100644 --- a/services/console/src/chunks/docs-reference/runner/ja/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/ja/runner-up.mdx @@ -52,6 +52,16 @@ Job を待機する際のロングポーリングのタイムアウト (秒)。` デフォルトでは、サーバーが新しいバージョンを提供すると、Runner は Job の合間に自身を更新します。 `BENCHER_NO_AUTO_UPDATE` 環境変数でも設定できます。 +### `--update-channel ` + +自動更新に使用する更新チャネル。`stable` または `canary` のいずれかです。 +デフォルトでは `stable` が使用されます。 +`stable` チャネルはバージョン付きリリースへ更新します。 +`canary` チャネルはローリング canary ビルドを追跡し、 +このビルドは Bencher Cloud のデプロイで Runner が変更されるたびに再ビルドされます。 +`BENCHER_UPDATE_CHANNEL` 環境変数でも設定できます。 +`--no-auto-update` と競合します。 + ### `--max-download-size ` 自己更新バイナリの最大ダウンロードサイズ (バイト)。 diff --git a/services/console/src/chunks/docs-reference/runner/ko/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/ko/runner-up.mdx index c792ddf03..1b6635605 100644 --- a/services/console/src/chunks/docs-reference/runner/ko/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/ko/runner-up.mdx @@ -53,6 +53,16 @@ Job을 기다리는 동안의 롱 폴(long-poll) 타임아웃(초)으로, `1`에 기본적으로, 서버가 새 버전을 제공하면 Runner는 Job 사이에 스스로 업데이트합니다. `BENCHER_NO_AUTO_UPDATE` 환경 변수로도 설정할 수 있습니다. +### `--update-channel ` + +자동 업데이트에 사용할 업데이트 채널로, `stable` 또는 `canary`입니다. +기본적으로 `stable`이 사용됩니다. +`stable` 채널은 버전이 지정된 릴리스로 업데이트합니다. +`canary` 채널은 롤링 canary 빌드를 추적하며, +이 빌드는 Bencher Cloud 배포에서 Runner가 변경될 때마다 다시 빌드됩니다. +`BENCHER_UPDATE_CHANNEL` 환경 변수로도 설정할 수 있습니다. +`--no-auto-update`와 충돌합니다. + ### `--max-download-size ` 자체 업데이트 바이너리의 최대 다운로드 크기(바이트)입니다. diff --git a/services/console/src/chunks/docs-reference/runner/pt/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/pt/runner-up.mdx index 1a31ff20a..df5d1fe17 100644 --- a/services/console/src/chunks/docs-reference/runner/pt/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/pt/runner-up.mdx @@ -53,6 +53,16 @@ Desabilita as atualizações automáticas do servidor. Por padrão, o runner se atualiza entre os Jobs quando o servidor oferece uma nova versão. Também pode ser definida com a variável de ambiente `BENCHER_NO_AUTO_UPDATE`. +### `--update-channel ` + +O canal de atualização para as atualizações automáticas, `stable` ou `canary`. +Por padrão, `stable` é usado. +O canal `stable` atualiza para releases versionadas. +O canal `canary` acompanha o build canary contínuo, +que é reconstruído sempre que o runner muda em um deploy do Bencher Cloud. +Também pode ser definido com a variável de ambiente `BENCHER_UPDATE_CHANNEL`. +Conflita com `--no-auto-update`. + ### `--max-download-size ` O tamanho máximo de download em bytes para binários de auto-atualização. diff --git a/services/console/src/chunks/docs-reference/runner/ru/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/ru/runner-up.mdx index f3309cac2..90f62beb0 100644 --- a/services/console/src/chunks/docs-reference/runner/ru/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/ru/runner-up.mdx @@ -53,6 +53,16 @@ UUID или slug Runner, от имени которого работать. По умолчанию runner обновляет себя между Job, когда сервер предлагает новую версию. Также может быть задано переменной окружения `BENCHER_NO_AUTO_UPDATE`. +### `--update-channel ` + +Канал автоматических обновлений: либо `stable`, либо `canary`. +По умолчанию используется `stable`. +Канал `stable` обновляет до версионированных релизов. +Канал `canary` отслеживает скользящую canary-сборку, +которая пересобирается всякий раз, когда runner меняется при деплое Bencher Cloud. +Также может быть задано переменной окружения `BENCHER_UPDATE_CHANNEL`. +Конфликтует с `--no-auto-update`. + ### `--max-download-size ` Максимальный размер загрузки в байтах для бинарных файлов самообновления. diff --git a/services/console/src/chunks/docs-reference/runner/zh/runner-up.mdx b/services/console/src/chunks/docs-reference/runner/zh/runner-up.mdx index e181e6985..cfb283b8f 100644 --- a/services/console/src/chunks/docs-reference/runner/zh/runner-up.mdx +++ b/services/console/src/chunks/docs-reference/runner/zh/runner-up.mdx @@ -52,6 +52,16 @@ runner up [OPTIONS] 默认情况下,当服务器提供新版本时,runner 会在 Job 之间自我更新。 也可以通过 `BENCHER_NO_AUTO_UPDATE` 环境变量设置。 +### `--update-channel ` + +自动更新使用的更新通道,`stable` 或 `canary`。 +默认使用 `stable`。 +`stable` 通道更新到版本化的发布版本。 +`canary` 通道跟踪滚动 canary 构建, +每当 runner 在 Bencher Cloud 部署中发生变化时,该构建都会重新构建。 +也可以通过 `BENCHER_UPDATE_CHANNEL` 环境变量设置。 +与 `--no-auto-update` 冲突。 + ### `--max-download-size ` 自我更新二进制文件的最大下载大小(字节)。 diff --git a/services/console/src/content/docs-explanation/de/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/de/self-hosted-runners.mdx index 24a1bab2a..2ade32a87 100644 --- a/services/console/src/content/docs-explanation/de/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/de/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Self-Hosted Runners" description: "So betreiben und verwalten Sie Ihren eigenen Bencher Bare Metal Runner mit dem runner-Binary" heading: "Self-Hosted Bare Metal Runners" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/en/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/en/self-hosted-runners.mdx index 4bbb773d0..1bef311e3 100644 --- a/services/console/src/content/docs-explanation/en/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/en/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Self-Hosted Runners" description: "How to run and manage your own Bencher Bare Metal Runner with the runner binary" heading: "Self-Hosted Bare Metal Runners" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/es/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/es/self-hosted-runners.mdx index 3c34c078e..5a1653e3d 100644 --- a/services/console/src/content/docs-explanation/es/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/es/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Runners Autoalojados" description: "Cómo ejecutar y gestionar tu propio Bare Metal Runner de Bencher con el binario runner" heading: "Bare Metal Runners Autoalojados" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/fr/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/fr/self-hosted-runners.mdx index f747f453b..b8996aeb0 100644 --- a/services/console/src/content/docs-explanation/fr/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/fr/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Runners auto-hébergés" description: "Comment exécuter et gérer votre propre Bencher Bare Metal Runner avec le binaire runner" heading: "Bare Metal Runners auto-hébergés" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/ja/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/ja/self-hosted-runners.mdx index 1e3405632..0f3223232 100644 --- a/services/console/src/content/docs-explanation/ja/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/ja/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "セルフホスト型 Runner" description: "runner バイナリで独自の Bencher Bare Metal Runner を実行・管理する方法" heading: "セルフホスト型 Bare Metal Runner" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/ko/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/ko/self-hosted-runners.mdx index 3f8ab9695..13dba71b1 100644 --- a/services/console/src/content/docs-explanation/ko/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/ko/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Self-Hosted Runner" description: "runner 바이너리로 자체 Bencher Bare Metal Runner를 실행하고 관리하는 방법" heading: "Self-Hosted Bare Metal Runner" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/pt/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/pt/self-hosted-runners.mdx index 19bd7c8fc..15f24ff75 100644 --- a/services/console/src/content/docs-explanation/pt/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/pt/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Self-Hosted Runners" description: "Como executar e gerenciar seu próprio Bencher Bare Metal Runner com o binário runner" heading: "Self-Hosted Bare Metal Runners" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/ru/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/ru/self-hosted-runners.mdx index 832f67900..c5d6ee8d4 100644 --- a/services/console/src/content/docs-explanation/ru/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/ru/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Self-Hosted Runner" description: "Как запускать и управлять собственным Bencher Bare Metal Runner с помощью бинарного файла runner" heading: "Self-Hosted Bare Metal Runner" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-explanation/zh/self-hosted-runners.mdx b/services/console/src/content/docs-explanation/zh/self-hosted-runners.mdx index 80f61548d..6d4260152 100644 --- a/services/console/src/content/docs-explanation/zh/self-hosted-runners.mdx +++ b/services/console/src/content/docs-explanation/zh/self-hosted-runners.mdx @@ -3,7 +3,7 @@ title: "Self-Hosted Runner" description: "如何使用 runner 二进制文件运行和管理你自己的 Bencher Bare Metal Runner" heading: "Self-Hosted Bare Metal Runner" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 11 --- diff --git a/services/console/src/content/docs-reference/de/runner-protocol.mdx b/services/console/src/content/docs-reference/de/runner-protocol.mdx index 65155b9fc..52ce9d914 100644 --- a/services/console/src/content/docs-reference/de/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/de/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner Protocol" description: "Referenz für das WebSocket-Protokoll zwischen einem Bencher Bare Metal Runner und dem API-Server" heading: "Bencher Runner Protocol" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/de/runner.mdx b/services/console/src/content/docs-reference/de/runner.mdx index 1d287fb90..b8871e0ee 100644 --- a/services/console/src/content/docs-reference/de/runner.mdx +++ b/services/console/src/content/docs-reference/de/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "Referenz für das Bencher runner-Binary zum Betrieb eines Self-Hosted Bare Metal Runner" heading: "Bencher `runner` CLI" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/en/runner-protocol.mdx b/services/console/src/content/docs-reference/en/runner-protocol.mdx index 8b896d9b0..e200c287f 100644 --- a/services/console/src/content/docs-reference/en/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/en/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner Protocol" description: "Reference for the WebSocket protocol between a Bencher Bare Metal Runner and the API server" heading: "Bencher Runner Protocol" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/en/runner.mdx b/services/console/src/content/docs-reference/en/runner.mdx index db375a3d4..a5280807f 100644 --- a/services/console/src/content/docs-reference/en/runner.mdx +++ b/services/console/src/content/docs-reference/en/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "Reference for the Bencher runner binary used to operate a Self-Hosted Bare Metal Runner" heading: "Bencher `runner` CLI" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/es/runner-protocol.mdx b/services/console/src/content/docs-reference/es/runner-protocol.mdx index 009f1de6a..47682f759 100644 --- a/services/console/src/content/docs-reference/es/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/es/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Protocolo Runner" description: "Referencia del protocolo WebSocket entre un Bare Metal Runner de Bencher y el servidor API" heading: "Bencher Protocolo Runner" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/es/runner.mdx b/services/console/src/content/docs-reference/es/runner.mdx index c096ca3ee..2c1272023 100644 --- a/services/console/src/content/docs-reference/es/runner.mdx +++ b/services/console/src/content/docs-reference/es/runner.mdx @@ -3,7 +3,7 @@ title: "CLI runner" description: "Referencia del binario runner de Bencher utilizado para operar un Bare Metal Runner Autoalojado" heading: "Bencher CLI `runner`" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/fr/runner-protocol.mdx b/services/console/src/content/docs-reference/fr/runner-protocol.mdx index 8edc9793d..dd6d6638e 100644 --- a/services/console/src/content/docs-reference/fr/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/fr/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Protocole Runner" description: "Référence du protocole WebSocket entre un Bencher Bare Metal Runner et le serveur API" heading: "Bencher Protocole Runner" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/fr/runner.mdx b/services/console/src/content/docs-reference/fr/runner.mdx index acab45b22..f37a2040e 100644 --- a/services/console/src/content/docs-reference/fr/runner.mdx +++ b/services/console/src/content/docs-reference/fr/runner.mdx @@ -3,7 +3,7 @@ title: "CLI Runner" description: "Référence du binaire runner de Bencher utilisé pour exploiter un Bare Metal Runner auto-hébergé" heading: "Bencher CLI `runner`" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/ja/runner-protocol.mdx b/services/console/src/content/docs-reference/ja/runner-protocol.mdx index 04bd409f7..2b9031016 100644 --- a/services/console/src/content/docs-reference/ja/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/ja/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner プロトコル" description: "Bencher Bare Metal Runner と API サーバー間の WebSocket プロトコルのリファレンス" heading: "Bencher Runner プロトコル" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/ja/runner.mdx b/services/console/src/content/docs-reference/ja/runner.mdx index 1ef900ea5..6d14a437c 100644 --- a/services/console/src/content/docs-reference/ja/runner.mdx +++ b/services/console/src/content/docs-reference/ja/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "セルフホスト型 Bare Metal Runner を運用するための Bencher runner バイナリのリファレンス" heading: "Bencher `runner` CLI" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/ko/runner-protocol.mdx b/services/console/src/content/docs-reference/ko/runner-protocol.mdx index 433fd69d5..6af0eb429 100644 --- a/services/console/src/content/docs-reference/ko/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/ko/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner Protocol" description: "Bencher Bare Metal Runner와 API 서버 간의 WebSocket 프로토콜 참조" heading: "Bencher Runner Protocol" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/ko/runner.mdx b/services/console/src/content/docs-reference/ko/runner.mdx index c6fa76c9c..93aaaadf3 100644 --- a/services/console/src/content/docs-reference/ko/runner.mdx +++ b/services/console/src/content/docs-reference/ko/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "Self-Hosted Bare Metal Runner를 운영하는 데 사용하는 Bencher runner 바이너리 참조" heading: "Bencher `runner` CLI" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/pt/runner-protocol.mdx b/services/console/src/content/docs-reference/pt/runner-protocol.mdx index c266eb3d7..0fa2e77d8 100644 --- a/services/console/src/content/docs-reference/pt/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/pt/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner Protocol" description: "Referência do protocolo WebSocket entre um Bencher Bare Metal Runner e o servidor de API" heading: "Bencher Runner Protocol" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/pt/runner.mdx b/services/console/src/content/docs-reference/pt/runner.mdx index fd883c03f..f7779d179 100644 --- a/services/console/src/content/docs-reference/pt/runner.mdx +++ b/services/console/src/content/docs-reference/pt/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "Referência do binário runner do Bencher usado para operar um Self-Hosted Bare Metal Runner" heading: "Bencher CLI `runner`" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/ru/runner-protocol.mdx b/services/console/src/content/docs-reference/ru/runner-protocol.mdx index 772c9e5d7..85967cd16 100644 --- a/services/console/src/content/docs-reference/ru/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/ru/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner Protocol" description: "Справочник по протоколу WebSocket между Bencher Bare Metal Runner и API-сервером" heading: "Bencher Runner Protocol" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/ru/runner.mdx b/services/console/src/content/docs-reference/ru/runner.mdx index b28322a11..f0206bda6 100644 --- a/services/console/src/content/docs-reference/ru/runner.mdx +++ b/services/console/src/content/docs-reference/ru/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "Справочник по бинарному файлу runner Bencher, используемому для работы с Self-Hosted Bare Metal Runner" heading: "Bencher CLI `runner`" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/content/docs-reference/zh/runner-protocol.mdx b/services/console/src/content/docs-reference/zh/runner-protocol.mdx index 3a61ae526..cafefe07d 100644 --- a/services/console/src/content/docs-reference/zh/runner-protocol.mdx +++ b/services/console/src/content/docs-reference/zh/runner-protocol.mdx @@ -3,7 +3,7 @@ title: "Runner 协议" description: "Bencher Bare Metal Runner 与 API 服务器之间 WebSocket 协议的参考" heading: "Bencher Runner 协议" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 9 --- diff --git a/services/console/src/content/docs-reference/zh/runner.mdx b/services/console/src/content/docs-reference/zh/runner.mdx index 2a41b61f0..ca3eeda9d 100644 --- a/services/console/src/content/docs-reference/zh/runner.mdx +++ b/services/console/src/content/docs-reference/zh/runner.mdx @@ -3,7 +3,7 @@ title: "Runner CLI" description: "用于操作 Self-Hosted Bare Metal Runner 的 Bencher runner 二进制文件参考" heading: "Bencher `runner` CLI" published: "2026-06-19T08:00:00Z" -modified: "2026-06-19T08:00:00Z" +modified: "2026-07-07T08:00:00Z" sortOrder: 8 --- diff --git a/services/console/src/types/bencher.ts b/services/console/src/types/bencher.ts index 25f9fee80..3817ff3e2 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -1267,3 +1267,8 @@ export enum RecaptchaAction { Login = "login", } +export enum UpdateChannel { + Stable = "stable", + Canary = "canary", +} + diff --git a/services/runner/Cargo.toml b/services/runner/Cargo.toml index df3eb5f5f..c96c4fde7 100644 --- a/services/runner/Cargo.toml +++ b/services/runner/Cargo.toml @@ -28,6 +28,9 @@ rustls = { workspace = true, optional = true } thiserror.workspace = true url = { workspace = true, optional = true } +[dev-dependencies] +pretty_assertions.workspace = true + [lints] workspace = true diff --git a/services/runner/src/parser/up.rs b/services/runner/src/parser/up.rs index c514855d8..8422adf47 100644 --- a/services/runner/src/parser/up.rs +++ b/services/runner/src/parser/up.rs @@ -55,7 +55,60 @@ pub struct CliUp { #[arg(long, env = "BENCHER_NO_AUTO_UPDATE")] pub no_auto_update: bool, + /// Update channel for automatic updates (default: stable). + /// The canary channel tracks the rolling Bencher Cloud runner build. + #[arg( + long, + env = "BENCHER_UPDATE_CHANNEL", + default_value_t, + conflicts_with = "no_auto_update" + )] + pub update_channel: bencher_json::UpdateChannel, + /// Maximum download size in bytes for self-update binaries (default: 500 MiB). #[arg(long, conflicts_with = "no_auto_update")] pub max_download_size: Option, } + +#[cfg(test)] +mod tests { + use bencher_json::UpdateChannel; + use clap::Parser as _; + use pretty_assertions::assert_eq; + + use super::CliUp; + + const BASE_ARGS: &[&str] = &[ + "up", + "--key", + "bencher_runner_aB3xY9mN2pQ7rS4tU8vW1zK5jL0fGh", + "--runner", + "00000000-0000-0000-0000-000000000000", + ]; + + fn parse(extra: &[&str]) -> Result { + CliUp::try_parse_from(BASE_ARGS.iter().chain(extra)) + } + + #[test] + fn update_channel_defaults_to_stable() { + let up = parse(&[]).unwrap(); + assert_eq!(up.update_channel, UpdateChannel::Stable); + } + + #[test] + fn update_channel_canary_parses() { + let up = parse(&["--update-channel", "canary"]).unwrap(); + assert_eq!(up.update_channel, UpdateChannel::Canary); + } + + #[test] + fn update_channel_invalid_rejected() { + parse(&["--update-channel", "nightly"]).unwrap_err(); + } + + #[test] + fn update_channel_conflicts_with_no_auto_update() { + parse(&["--update-channel", "canary", "--no-auto-update"]).unwrap_err(); + } +} diff --git a/services/runner/src/runner/up.rs b/services/runner/src/runner/up.rs index 54a6cea9c..0407dfd51 100644 --- a/services/runner/src/runner/up.rs +++ b/services/runner/src/runner/up.rs @@ -29,6 +29,7 @@ impl TryFrom for Up { sandbox_log_level: task.sandbox_log_level, allow_no_sandbox: task.danger_allow_no_sandbox, no_auto_update: task.no_auto_update, + update_channel: task.update_channel, max_download_size: task.max_download_size, }, }) diff --git a/tasks/runner_ops/CLAUDE.md b/tasks/runner_ops/CLAUDE.md index e4d9172e5..8ba4ceacd 100644 --- a/tasks/runner_ops/CLAUDE.md +++ b/tasks/runner_ops/CLAUDE.md @@ -6,6 +6,10 @@ Operational tooling for managing bare metal runner servers. Invoked via `cargo o Servers are configured in `tasks/runner_ops/runners.json`. Each entry maps a runner name to its SSH connection details and runner key. The runner name is used as the first positional argument in all commands. +Optional per-runner fields: + +- `"update_channel": "canary"` puts the runner on the canary update channel: it self-updates to the rolling canary build published on each `cloud` branch deploy, instead of waiting for versioned releases. Omit (or use `"stable"`) for release-only updates. The channel is written to the systemd drop-in as `BENCHER_UPDATE_CHANNEL` by `deploy` and `start`; both commands also accept an `--update-channel` flag that overrides the file. + ## Common Operations ### Deploy a tagged release @@ -23,6 +27,15 @@ cargo ops deploy --run-id cargo ops deploy ``` +### Deploy a canary (cloud branch) build + +Canary channel runners self-update automatically after each `cloud` push, so this is only needed to bootstrap a runner onto the channel or to force an immediate deploy: + +```bash +gh run list --repo bencherdev/bencher --branch cloud --workflow ci.yml --status success --json databaseId -L 1 +cargo ops deploy --run-id --update-channel canary +``` + ### Start/stop/logs ```bash diff --git a/tasks/runner_ops/src/parser/mod.rs b/tasks/runner_ops/src/parser/mod.rs index 1251d164e..b112d7e15 100644 --- a/tasks/runner_ops/src/parser/mod.rs +++ b/tasks/runner_ops/src/parser/mod.rs @@ -73,6 +73,10 @@ pub struct TaskDeploy { #[clap(long)] pub host: Option, + /// Update channel for automatic updates (stable or canary) + #[clap(long)] + pub update_channel: Option, + /// GitHub Actions run ID (defaults to latest successful `devel` run) #[clap(long)] pub run_id: Option, @@ -121,6 +125,10 @@ pub struct TaskStart { #[clap(long)] pub host: Option, + /// Update channel for automatic updates (stable or canary) + #[clap(long)] + pub update_channel: Option, + /// Allow executing jobs without a sandbox (sets `BENCHER_DANGER_ALLOW_NO_SANDBOX=true`). #[clap(long)] pub danger_allow_no_sandbox: bool, diff --git a/tasks/runner_ops/src/parser/server.rs b/tasks/runner_ops/src/parser/server.rs index afa6a7b2d..bcd3ee46a 100644 --- a/tasks/runner_ops/src/parser/server.rs +++ b/tasks/runner_ops/src/parser/server.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use bencher_json::{RunnerResourceId, Secret}; +use bencher_json::{RunnerResourceId, Secret, UpdateChannel}; use camino::Utf8PathBuf; use serde::Deserialize; @@ -17,6 +17,7 @@ pub struct Server { pub user: Option, pub key: Option, pub host: Option, + pub update_channel: Option, } pub fn load_server(runner: &RunnerResourceId) -> anyhow::Result> { diff --git a/tasks/runner_ops/src/task/deploy.rs b/tasks/runner_ops/src/task/deploy.rs index 3eb893756..14001925e 100644 --- a/tasks/runner_ops/src/task/deploy.rs +++ b/tasks/runner_ops/src/task/deploy.rs @@ -1,4 +1,4 @@ -use bencher_json::{RunnerResourceId, Secret}; +use bencher_json::{RunnerResourceId, Secret, UpdateChannel}; use super::deploy_setup; use super::download; @@ -13,6 +13,7 @@ pub struct Deploy { host: url::Url, runner: RunnerResourceId, key: Secret, + update_channel: Option, run_id: Option, } @@ -27,14 +28,17 @@ impl TryFrom for Deploy { user, key, host, + update_channel, run_id, } = task; - let (ssh, host, runner, key) = merge_ssh_with_extras(runner, server, ssh, user, key, host)?; + let (ssh, host, runner, key, update_channel) = + merge_ssh_with_extras(runner, server, ssh, user, key, host, update_channel)?; Ok(Self { ssh, host, runner, key, + update_channel, run_id, }) } @@ -47,11 +51,12 @@ impl Deploy { host, runner, key, + update_channel, run_id, } = self; let (runner_binary, _temp_dir) = download::download(run_id)?; deploy_setup::deploy(&ssh, Some(runner_binary.as_path()))?; - let start = Start::new(ssh, host, runner, key, false); + let start = Start::new(ssh, host, runner, key, update_channel, false); start.exec()?; Ok(()) } diff --git a/tasks/runner_ops/src/task/download.rs b/tasks/runner_ops/src/task/download.rs index 508888aec..7e97b81c8 100644 --- a/tasks/runner_ops/src/task/download.rs +++ b/tasks/runner_ops/src/task/download.rs @@ -18,7 +18,10 @@ pub fn download(run_id: Option) -> anyhow::Result<(Utf8PathBuf, TempDir)> { (id, DEFAULT_BRANCH.into()) }; - let artifact_name = format!("runner-{branch}-linux-x86-64"); + // Runner artifacts are named by branch, except on `cloud` where the + // build is versioned as `canary` for the rolling canary prerelease. + let artifact_version = if branch == "cloud" { "canary" } else { &branch }; + let artifact_name = format!("runner-{artifact_version}-linux-x86-64"); println!("Downloading artifact {artifact_name} from run {run_id}..."); let temp_dir = tempfile::tempdir()?; diff --git a/tasks/runner_ops/src/task/mod.rs b/tasks/runner_ops/src/task/mod.rs index b82e0ebd3..22a2dfc8e 100644 --- a/tasks/runner_ops/src/task/mod.rs +++ b/tasks/runner_ops/src/task/mod.rs @@ -111,7 +111,7 @@ fn merge_ssh( Ok((server, ssh, user)) } -/// Merge SSH + key/host fields from CLI flags and server config file. +/// Merge SSH + key/host/channel fields from CLI flags and server config file. fn merge_ssh_with_extras( runner: RunnerResourceId, server: Option, @@ -119,7 +119,14 @@ fn merge_ssh_with_extras( user: Option, key: Option, host: Option, -) -> anyhow::Result<(Ssh, url::Url, RunnerResourceId, Secret)> { + update_channel: Option, +) -> anyhow::Result<( + Ssh, + url::Url, + RunnerResourceId, + Secret, + Option, +)> { let file = load_server(&runner)?; let (server, ssh, user) = merge_ssh(file.as_ref(), server, ssh, user)?; let key = key @@ -128,5 +135,12 @@ fn merge_ssh_with_extras( let host = host .or(file.as_ref().and_then(|f| f.host.clone())) .unwrap_or_else(|| DEFAULT_HOST.clone()); - Ok((Ssh::new(server, ssh, user), host, runner, key)) + let update_channel = update_channel.or(file.as_ref().and_then(|f| f.update_channel)); + Ok(( + Ssh::new(server, ssh, user), + host, + runner, + key, + update_channel, + )) } diff --git a/tasks/runner_ops/src/task/start.rs b/tasks/runner_ops/src/task/start.rs index 3101f41ac..17c7e77fc 100644 --- a/tasks/runner_ops/src/task/start.rs +++ b/tasks/runner_ops/src/task/start.rs @@ -1,4 +1,4 @@ -use bencher_json::{RunnerResourceId, Secret}; +use bencher_json::{RunnerResourceId, Secret, UpdateChannel}; use super::merge_ssh_with_extras; use super::ssh::Ssh; @@ -10,6 +10,7 @@ pub struct Start { host: url::Url, runner: RunnerResourceId, key: Secret, + update_channel: Option, danger_allow_no_sandbox: bool, } @@ -24,14 +25,17 @@ impl TryFrom for Start { user, key, host, + update_channel, danger_allow_no_sandbox, } = task; - let (ssh, host, runner, key) = merge_ssh_with_extras(runner, server, ssh, user, key, host)?; + let (ssh, host, runner, key, update_channel) = + merge_ssh_with_extras(runner, server, ssh, user, key, host, update_channel)?; Ok(Self { ssh, host, runner, key, + update_channel, danger_allow_no_sandbox, }) } @@ -43,6 +47,7 @@ impl Start { host: url::Url, runner: RunnerResourceId, key: Secret, + update_channel: Option, danger_allow_no_sandbox: bool, ) -> Self { Self { @@ -50,6 +55,7 @@ impl Start { host, runner, key, + update_channel, danger_allow_no_sandbox, } } @@ -60,24 +66,22 @@ impl Start { host, runner, key, + update_channel, danger_allow_no_sandbox, } = self; println!("Configuring runner credentials..."); ssh.run("mkdir -p /etc/systemd/system/bencher-runner.service.d")?; - let no_sandbox_env = if danger_allow_no_sandbox { - "Environment=BENCHER_DANGER_ALLOW_NO_SANDBOX=true\n" - } else { - "" - }; + let credentials = credentials_conf( + &host, + &runner, + key.as_ref(), + update_channel, + danger_allow_no_sandbox, + ); ssh.run(&format!( "cat > /etc/systemd/system/bencher-runner.service.d/credentials.conf << 'CRED_EOF'\n\ - [Service]\n\ - Environment=BENCHER_HOST={host}\n\ - Environment=BENCHER_RUNNER={runner}\n\ - Environment=BENCHER_RUNNER_KEY={key}\n\ - {no_sandbox_env}\ - CRED_EOF", - key = key.as_ref(), + {credentials}\ + CRED_EOF" ))?; println!("Starting runner service..."); ssh.run("systemctl daemon-reload")?; @@ -87,3 +91,72 @@ impl Start { Ok(()) } } + +/// Build the contents of the systemd credentials drop-in. +fn credentials_conf( + host: &url::Url, + runner: &RunnerResourceId, + key: &str, + update_channel: Option, + danger_allow_no_sandbox: bool, +) -> String { + let channel_env = update_channel.map_or_else(String::new, |channel| { + format!("Environment=BENCHER_UPDATE_CHANNEL={channel}\n") + }); + let no_sandbox_env = if danger_allow_no_sandbox { + "Environment=BENCHER_DANGER_ALLOW_NO_SANDBOX=true\n" + } else { + "" + }; + format!( + "[Service]\n\ + Environment=BENCHER_HOST={host}\n\ + Environment=BENCHER_RUNNER={runner}\n\ + Environment=BENCHER_RUNNER_KEY={key}\n\ + {channel_env}\ + {no_sandbox_env}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_host() -> url::Url { + "https://api.example.com".parse().unwrap() + } + + fn test_runner() -> RunnerResourceId { + "test-runner".parse().unwrap() + } + + #[test] + fn credentials_conf_minimal() { + let conf = credentials_conf(&test_host(), &test_runner(), "secret-key", None, false); + assert_eq!( + conf, + "[Service]\n\ + Environment=BENCHER_HOST=https://api.example.com/\n\ + Environment=BENCHER_RUNNER=test-runner\n\ + Environment=BENCHER_RUNNER_KEY=secret-key\n" + ); + } + + #[test] + fn credentials_conf_with_channel() { + let conf = credentials_conf( + &test_host(), + &test_runner(), + "secret-key", + Some(UpdateChannel::Canary), + false, + ); + assert!(conf.contains("Environment=BENCHER_UPDATE_CHANNEL=canary\n")); + } + + #[test] + fn credentials_conf_with_no_sandbox() { + let conf = credentials_conf(&test_host(), &test_runner(), "secret-key", None, true); + assert!(conf.contains("Environment=BENCHER_DANGER_ALLOW_NO_SANDBOX=true\n")); + } +}