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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Relay Deploy: Live Logs + Docker Cache Invalidation

**Date:** 2026-07-19
**Status:** Approved for implementation

## Problems

1. Deploy wizard log pane stays empty until the remote task finishes or fails.
2. Docker image rebuild fails with `cannot find db/admin in bitfun_relay_service`.

## Root Causes

1. Detached `nohup` redirects stdout to a file (full buffering); poll splitter is fragile on CRLF; frontend `setInterval` delays the first poll and can overlap.
2. Dockerfile dependency-cache cleanup uses `deps/bitfun_relay_service*`, which does not match Cargo artifacts `libbitfun_relay_service-*`; the second build links the empty placeholder crate.

## Design

### Docker

In `src/apps/relay-server/Dockerfile`, invalidate placeholder artifacts with globs `*bitfun_relay_service*`, `*bitfun_relay_server*`, `*relay_admin*`, remove matching `.fingerprint` dirs, and `touch` real sources before the second `cargo build`.

### Live logs

- Launch detached tasks with `stdbuf -oL -eL` when available.
- Set `BUILDKIT_PROGRESS=plain` (and compose `--progress=plain` when supported).
- Split poll stdout on `---\n`, `---\r\n`, or a trimmed `---` line.
- Frontend: immediate first poll + serial `setTimeout` chain; seed a waiting line via i18n.

### Out of scope

- WebSocket log push / PTY
- Configurable deploy git ref (default remains GitHub `main`)

## Verification

- Focused Rust check for `bitfun-services-integrations`
- Web type-check
- Local `docker compose build` for relay-server when feasible
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod miniapp_api;
pub mod miniapp_export_api;
pub mod path_target;
pub mod peer_host_invoke;
pub mod relay_deploy_api;
pub mod remote_connect_api;
pub mod remote_workspace_policy;
pub mod review_platform_api;
Expand Down
153 changes: 153 additions & 0 deletions src/apps/desktop/src/api/relay_deploy_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! Relay server self-deploy Tauri commands.
//!
//! Lets a user deploy the open-source BitFun relay server to their own host
//! over an existing SSH connection (preflight → Docker install → source
//! download + compose deploy → account import). The account is provisioned
//! locally: the plaintext password never leaves this machine — only Argon2id
//! derived artifacts are transferred and handed to `relay-admin import-user`.

use bitfun_core::service::remote_ssh::relay_deploy::{
self, RelayDeployTask, RelayPreflight, RelayTaskPoll,
};
use serde::Serialize;
use tauri::State;

use super::app_state::AppState;

#[tauri::command]
pub async fn relay_deploy_preflight(
state: State<'_, AppState>,
connection_id: String,
) -> Result<RelayPreflight, String> {
let manager = state
.get_ssh_manager_async()
.await
.map_err(|e| e.to_string())?;
relay_deploy::run_preflight(&manager, &connection_id)
.await
.map_err(|e| e.to_string())
}

/// Start Docker installation on the remote host (detached; poll via
/// `relay_deploy_poll` with task `install_docker`).
#[tauri::command]
pub async fn relay_deploy_install_docker(
state: State<'_, AppState>,
connection_id: String,
) -> Result<(), String> {
let manager = state
.get_ssh_manager_async()
.await
.map_err(|e| e.to_string())?;
relay_deploy::start_task(&manager, &connection_id, RelayDeployTask::InstallDocker)
.await
.map_err(|e| e.to_string())
}

/// Start the relay deployment on the remote host (detached; poll via
/// `relay_deploy_poll` with task `deploy`).
#[tauri::command]
pub async fn relay_deploy_start(
state: State<'_, AppState>,
connection_id: String,
) -> Result<(), String> {
let manager = state
.get_ssh_manager_async()
.await
.map_err(|e| e.to_string())?;
relay_deploy::start_task(&manager, &connection_id, RelayDeployTask::Deploy)
.await
.map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn relay_deploy_poll(
state: State<'_, AppState>,
connection_id: String,
task: RelayDeployTask,
cursor: u64,
) -> Result<RelayTaskPoll, String> {
let manager = state
.get_ssh_manager_async()
.await
.map_err(|e| e.to_string())?;
relay_deploy::poll_task(&manager, &connection_id, task, cursor)
.await
.map_err(|e| e.to_string())
}

/// Provision a relay account locally and import it into the deployed relay.
///
/// The plaintext password is consumed only by the local Argon2id/AES-GCM
/// provisioning step; it is never transmitted to the server.
#[tauri::command]
pub async fn relay_deploy_register(
state: State<'_, AppState>,
connection_id: String,
username: String,
password: String,
) -> Result<(), String> {
let username = username.trim().to_string();
if username.is_empty() || username.chars().any(char::is_whitespace) {
return Err("invalid username".to_string());
}
if password.len() < 8 {
return Err("password must be at least 8 characters".to_string());
}
let account = bitfun_relay_service::admin::provision(&username, &password)
.map_err(|e| format!("provision account: {e}"))?;
let import = bitfun_relay_service::admin::ImportableAccount { username, account };
let json = serde_json::to_string(&import).map_err(|e| format!("serialize account: {e}"))?;
let manager = state
.get_ssh_manager_async()
.await
.map_err(|e| e.to_string())?;
relay_deploy::import_account(&manager, &connection_id, &json)
.await
.map_err(|e| e.to_string())
}

/// Client-side reachability check for a relay URL (catches firewalls /
/// security-group rules that block the relay port from the public internet).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RelayVerifyResult {
pub reachable: bool,
pub version: Option<String>,
}

#[tauri::command]
pub async fn relay_deploy_verify(relay_url: String) -> Result<RelayVerifyResult, String> {
let base = relay_url.trim().trim_end_matches('/').to_string();
if base.is_empty() {
return Err("empty relay url".to_string());
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.map_err(|e| e.to_string())?;
let health_ok = client
.get(format!("{base}/health"))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
if !health_ok {
return Ok(RelayVerifyResult {
reachable: false,
version: None,
});
}
let version = match client.get(format!("{base}/api/info")).send().await {
Ok(r) => r
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from)),
Err(_) => None,
};
Ok(RelayVerifyResult {
reachable: true,
version,
})
}
31 changes: 31 additions & 0 deletions src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@ pub fn init_on_startup() {
};
*get_account_session().write().await = Some(session);
*get_account_relay_url().write().await = Some(relay_url.clone());
// Keep the mirrored "Self-Hosted" server field in sync for
// sessions restored from an older version without the mirror.
set_self_hosted_form_url(Some(&relay_url));
log::info!("Restored account session for user {user_id}");

// Initialize the remote-connect service if not yet ready.
Expand Down Expand Up @@ -1236,6 +1239,9 @@ pub async fn account_login(request: AccountAuthRequest) -> Result<AccountLoginRe
*get_account_relay_url().write().await = Some(request.relay_url.clone());
// Persist non-secret credentials for next startup pre-fill
save_credential_hint(&request.username, &request.relay_url);
// Mirror the relay URL into the Remote Connect "Self-Hosted" server field
// so phone pairing can ride the same relay the account is logged into.
set_self_hosted_form_url(Some(&request.relay_url));
// Persist the full session (token + master_key, encrypted) so the
// user stays logged in across app restarts.
if let Err(e) = session_store::save_session_with_device(
Expand All @@ -1252,6 +1258,14 @@ pub async fn account_login(request: AccountAuthRequest) -> Result<AccountLoginRe

register_delegated_identity_providers().await;

emit_account_event(
"account://login-state",
serde_json::json!({
"logged_in": true,
"relay_url": request.relay_url,
}),
);

log::info!(
"Account logged in: {} (has_cloud_settings={})",
result.user_id,
Expand Down Expand Up @@ -1285,11 +1299,28 @@ pub async fn account_logout() -> Result<(), String> {
*get_account_relay_url().write().await = None;
clear_credential_hint();
session_store::clear_session();
// Clear the mirrored "Self-Hosted" server field on logout.
set_self_hosted_form_url(None);
TOKEN_EXPIRED.store(false, std::sync::atomic::Ordering::Relaxed);
emit_account_event(
"account://login-state",
serde_json::json!({ "logged_in": false }),
);
log::info!("Account logged out");
Ok(())
}

/// Persist (or clear) the account relay URL in the Remote Connect
/// "Self-Hosted" form field so the pairing UI follows account login state.
fn set_self_hosted_form_url(url: Option<&str>) {
let mut data = bot::load_bot_persistence();
let value = url.unwrap_or_default();
if data.form_state.custom_server_url != value {
data.form_state.custom_server_url = value.to_string();
bot::save_bot_persistence(&data);
}
}

// ── P2: Device routing commands ──────────────────────────────────────────

#[derive(Serialize)]
Expand Down
24 changes: 24 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,30 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::LegacyUnaudited,
),
("reload_subagents", RemoteWorkspacePolicy::LegacyUnaudited),
(
"relay_deploy_install_docker",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"relay_deploy_poll",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"relay_deploy_preflight",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"relay_deploy_register",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"relay_deploy_start",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"relay_deploy_verify",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"remote_close_workspace",
RemoteWorkspacePolicy::RemoteRouted,
Expand Down
7 changes: 7 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,13 @@ pub async fn run() {
api::ssh_api::remote_close_workspace,
api::ssh_api::remote_remove_workspace,
api::ssh_api::remote_get_workspace_info,
// Relay self-deploy API
api::relay_deploy_api::relay_deploy_preflight,
api::relay_deploy_api::relay_deploy_install_docker,
api::relay_deploy_api::relay_deploy_start,
api::relay_deploy_api::relay_deploy_poll,
api::relay_deploy_api::relay_deploy_register,
api::relay_deploy_api::relay_deploy_verify,
// Announcement / feature-demo / tips API
api::announcement_api::get_pending_announcements,
api::announcement_api::mark_announcement_seen,
Expand Down
1 change: 1 addition & 0 deletions src/apps/relay-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["clock"] }
clap = { version = "4", features = ["derive", "env"] }
rpassword = "7"
serde_json = "1.0"

[lints.rust]
unsafe_op_in_unsafe_fn = "warn"
Expand Down
19 changes: 15 additions & 4 deletions src/apps/relay-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,35 @@ COPY src/apps/relay-server/Cargo.toml ./Cargo.toml
COPY src/crates/services/relay-service/Cargo.toml ../../crates/services/relay-service/Cargo.toml

# Build placeholders first so unchanged dependencies remain cached.
# BuildKit exposes ARG as an env during RUN; cargo rejects CARGO_BUILD_JOBS="".
RUN mkdir -p src/bin ../../crates/services/relay-service/src \
&& printf 'fn main() {}\n' > src/main.rs \
&& printf 'pub use bitfun_relay_service::*;\n' > src/lib.rs \
&& printf 'fn main() {}\n' > src/bin/relay_admin.rs \
&& printf '// placeholder\n' > ../../crates/services/relay-service/src/lib.rs \
&& { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \
&& cargo build --release

# Cargo emits libbitfun_relay_service-*.rlib/.rmeta — a bare bitfun_relay_service*
# glob misses those and leaves the empty placeholder crate for the real rebuild.
RUN rm -rf src ../../crates/services/relay-service/src \
target/release/bitfun-relay-server \
target/release/relay-admin \
target/release/deps/bitfun_relay_service* \
target/release/deps/bitfun_relay_server* \
target/release/deps/relay_admin*
target/release/deps/*bitfun_relay_service* \
target/release/deps/*bitfun_relay_server* \
target/release/deps/*relay_admin* \
target/release/.fingerprint/bitfun-relay-service-* \
target/release/.fingerprint/bitfun-relay-server-* \
target/release/.fingerprint/relay-admin-*

COPY src/apps/relay-server/src/ ./src/
COPY src/crates/services/relay-service/src/ ../../crates/services/relay-service/src/

RUN if [ -n "${CARGO_BUILD_JOBS}" ]; then export CARGO_BUILD_JOBS; fi \
RUN touch ../../crates/services/relay-service/src/lib.rs \
src/lib.rs \
src/main.rs \
src/bin/relay_admin.rs \
&& { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \
&& cargo build --release \
&& (strip target/release/bitfun-relay-server target/release/relay-admin || true)

Expand Down
9 changes: 8 additions & 1 deletion src/apps/relay-server/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,16 @@ else
BUILD_ARGS+=(--build-arg "CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS}")
echo " Using CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS}"
fi
# Plain progress so nohup/file-redirected deploys still stream build lines.
export BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS:-plain}"
# Do not pass --platform unless the user explicitly set DOCKER_DEFAULT_PLATFORM;
# native builds on amd64/arm64 servers are the supported path.
compose build "${BUILD_ARGS[@]}"
# Compose V2 wants --progress as a global flag; legacy docker-compose has none.
if [ "${#COMPOSE[@]}" -ge 2 ] && [ "${COMPOSE[0]}" = "docker" ] && [ "${COMPOSE[1]}" = "compose" ]; then
docker compose --progress=plain build "${BUILD_ARGS[@]}"
else
compose build "${BUILD_ARGS[@]}"
fi
fi

echo "[2/2] Starting / recreating services..."
Expand Down
Loading
Loading