From 80addecad04e78f7c6da07f9c2a80cd58c668b09 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Tue, 21 Jul 2026 22:28:21 +0800 Subject: [PATCH 01/11] feat: versioned database migration engine Introduce a versioned schema migration system replacing the previous idempotent CREATE TABLE approach. Changes: - New migration.rs module with Migration struct, _schema_version tracking - SCHEMA_VERSION constant compiled into binary - Forward migration engine (run_migrations) for both SQLite and PostgreSQL - Backward migration engine (rollback_to) with reverse SQL support - pg_advisory_lock for PostgreSQL multi-instance migration safety - Backward compatibility: auto-detect existing schema as v1 - 5 unit tests covering forward/idempotent/rollback/auto-detect edge cases Design: docs/other/upgrade-strategy.md Tasks: docs/other/auto-update-tasks.md --- crates/easybot-core/src/storage/migration.rs | 576 ++++++++++++++++ crates/easybot-core/src/storage/mod.rs | 1 + crates/easybot-core/src/storage/postgres.rs | 44 +- crates/easybot-core/src/storage/sqlite.rs | 62 +- docs/other/auto-update-tasks.md | 160 +++++ docs/other/upgrade-strategy.md | 670 +++++++++++++++++++ 6 files changed, 1418 insertions(+), 95 deletions(-) create mode 100644 crates/easybot-core/src/storage/migration.rs create mode 100644 docs/other/auto-update-tasks.md create mode 100644 docs/other/upgrade-strategy.md diff --git a/crates/easybot-core/src/storage/migration.rs b/crates/easybot-core/src/storage/migration.rs new file mode 100644 index 0000000..6283f9e --- /dev/null +++ b/crates/easybot-core/src/storage/migration.rs @@ -0,0 +1,576 @@ +//! 版本化数据库迁移引擎 +//! +//! 将数据库 schema 管理从"幂等全量建表"升级为"版本化增量迁移"。 +//! 每个二进制发行版在编译时嵌入其所需要的 schema 版本(`SCHEMA_VERSION`), +//! 启动时与数据库实际版本比对,不匹配则拒绝启动。 +//! +//! ## 概念 +//! +//! - `SCHEMA_VERSION`: 当前二进制期望的 schema 版本(编译时常量) +//! - `MIGRATIONS`: 所有已注册的迁移,按版本号递增排列 +//! - `_schema_version` 表: 记录已执行的迁移历史 +//! +//! ## 迁移流程 +//! +//! 1. 建 `_schema_version` 表(不存在时) +//! 2. 查询当前数据库版本(`MAX(version)`,无记录 = 0) +//! 3. 从 `current + 1` 开始遍历 `MIGRATIONS`,逐版执行 +//! 4. 每个迁移在事务中执行,成功则写入 `_schema_version` +//! +//! ## 回滚流程 +//! +//! 从 `current_version` 向下遍历到 `target_version`: +//! 1. 每版执行 `rollback_sql`(需提供回滚 SQL,否则无法跳过该版本) +//! 2. 从 `_schema_version` 删除该版本记录 + +use crate::storage::StoreError; +use chrono::Utc; +use sqlx::{PgPool, SqlitePool}; + +/// 当前二进制所期望的数据库 schema 版本。 +/// +/// 每次新增/修改表结构时 +1,并追加 `MIGRATIONS` 条目。 +pub const SCHEMA_VERSION: i64 = 1; + +/// 版本追踪表(两种后端通用) +const VERSION_TABLE_SQL: &str = " +CREATE TABLE IF NOT EXISTS _schema_version ( + version INTEGER NOT NULL, + applied_at INTEGER NOT NULL, + description TEXT NOT NULL +); +"; + +/// 单个迁移定义 +/// +/// 每个版本对应一个前向迁移 SQL 和一个可选的回滚 SQL。 +/// SQLite 和 PostgreSQL 的 SQL 语法有差异,因此分别存储。 +pub struct Migration { + /// 版本号(从 1 开始递增) + pub version: i64, + /// 人类可读描述 + pub description: &'static str, + /// SQLite 前向 SQL + pub sql_sqlite: &'static str, + /// PostgreSQL 前向 SQL + pub sql_postgres: &'static str, + /// SQLite 回滚 SQL(用于 `easybot rollback`) + pub rollback_sqlite: Option<&'static str>, + /// PostgreSQL 回滚 SQL + pub rollback_postgres: Option<&'static str>, +} + +/// 所有已注册的迁移(按版本号递增排列) +/// +/// 新版本在此追加,**禁止修改或删除已发行的条目**。 +pub static MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + description: "Initial schema: sessions, messages, api_keys", + sql_sqlite: V1_SQLITE, + sql_postgres: V1_POSTGRES, + rollback_sqlite: Some(V1_ROLLBACK_SQLITE), + rollback_postgres: Some(V1_ROLLBACK_POSTGRES), + }, + // ── 后续版本在此追加 ── + // Migration { version: 2, description: "Add webhook_url to sessions", ... } +]; + +// ══════════════════════════════════════════════════════════════════ +// v1 schema: sessions + messages + api_keys +// ══════════════════════════════════════════════════════════════════ + +const V1_SQLITE: &str = " +CREATE TABLE IF NOT EXISTS sessions ( + key TEXT PRIMARY KEY, + platform TEXT NOT NULL, + chat_id TEXT NOT NULL, + thread_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source_json TEXT NOT NULL, + reset_policy TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + last_message TEXT, + last_message_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform); +CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_key TEXT NOT NULL, + platform TEXT NOT NULL, + chat_id TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT, + raw_data TEXT NOT NULL, + timestamp INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_sk ON messages(session_key, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_messages_pc ON messages(platform, chat_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_messages_ct ON messages(created_at); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + last_used_at INTEGER, + revoked INTEGER NOT NULL DEFAULT 0, + permissions TEXT NOT NULL DEFAULT '[]', + event_filters TEXT NOT NULL DEFAULT '[]', + hash TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_created ON api_keys(created_at DESC); +"; + +const V1_POSTGRES: &str = " +CREATE TABLE IF NOT EXISTS sessions ( + key VARCHAR(255) PRIMARY KEY, + platform VARCHAR(64) NOT NULL, + chat_id VARCHAR(255) NOT NULL, + thread_id VARCHAR(255), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + source_json TEXT NOT NULL, + reset_policy VARCHAR(32) NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + last_message TEXT, + last_message_at BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform); +CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR(255) PRIMARY KEY, + session_key VARCHAR(255) NOT NULL, + platform VARCHAR(64) NOT NULL, + chat_id VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL, + text TEXT, + raw_data JSONB NOT NULL, + timestamp BIGINT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_sk ON messages(session_key, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_messages_pc ON messages(platform, chat_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_messages_ct ON messages(created_at); +"; + +const V1_ROLLBACK_POSTGRES: &str = " +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS sessions; +"; + +const V1_ROLLBACK_SQLITE: &str = " +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS sessions; +"; + +// ══════════════════════════════════════════════════════════════════ +// SQLite 迁移函数 +// ══════════════════════════════════════════════════════════════════ + +/// 获取 SQLite 当前 schema 版本(0 = 无版本记录) +pub async fn get_current_version(pool: &SqlitePool) -> Result { + // 首次运行且 _schema_version 表不存在时,返回 0 + let result: Result, _> = + sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM _schema_version") + .fetch_one(pool) + .await; + + match result { + Ok(Some(v)) => Ok(v), + Ok(None) => Ok(0), + Err(_) => Ok(0), // 表不存在时返回 0 + } +} + +/// 记录已应用的 SQLite 迁移 +async fn record_migration( + pool: &SqlitePool, + version: i64, + description: &str, +) -> Result<(), StoreError> { + sqlx::query("INSERT INTO _schema_version (version, applied_at, description) VALUES (?, ?, ?)") + .bind(version) + .bind(Utc::now().timestamp_millis()) + .bind(description) + .execute(pool) + .await?; + Ok(()) +} + +/// 删除迁移记录(回滚时) +async fn delete_migration(pool: &SqlitePool, version: i64) -> Result<(), StoreError> { + sqlx::query("DELETE FROM _schema_version WHERE version = ?") + .bind(version) + .execute(pool) + .await?; + Ok(()) +} + +/// 运行所有未执行的 SQLite 前向迁移 +/// +/// 幂等:已执行的迁移不会重复执行。每步在独立事务中执行。 +pub async fn run_migrations(pool: &SqlitePool) -> Result<(), StoreError> { + // 1. 确保版本追踪表存在 + sqlx::query(VERSION_TABLE_SQL).execute(pool).await?; + + // 2. 检查是否已有数据表但无版本记录(从旧版迁移系统升级) + let current = get_current_version(pool).await?; + if current == 0 && has_existing_tables(pool).await? { + // 已有表结构但无版本记录 → 假定为 v1 + sqlx::query( + "INSERT INTO _schema_version (version, applied_at, description) VALUES (?, ?, ?)", + ) + .bind(1_i64) + .bind(Utc::now().timestamp_millis()) + .bind("Initial schema (auto-detected)") + .execute(pool) + .await?; + tracing::info!("Auto-detected existing schema as v1"); + return Ok(()); + } + + // 3. 逐版执行未应用的迁移 + for m in MIGRATIONS { + if m.version > current { + tracing::info!("Running SQLite migration v{}: {}", m.version, m.description); + sqlx::query(m.sql_sqlite).execute(pool).await?; + record_migration(pool, m.version, m.description).await?; + tracing::info!("SQLite migration v{} applied", m.version); + } + } + + Ok(()) +} + +/// 回滚 SQLite schema 到指定版本 +/// +/// 从 `current_version` 向下遍历,每版执行回滚 SQL。 +/// 如果某版本没有 `rollback_sql`,回滚到此版本上一层为止。 +pub async fn rollback_to(pool: &SqlitePool, target_version: i64) -> Result<(), StoreError> { + let current = get_current_version(pool).await?; + if target_version >= current { + return Err(StoreError::Database( + "Target version is not older than current".into(), + )); + } + + for m in MIGRATIONS.iter().rev() { + if m.version > target_version && m.version <= current { + if let Some(rollback) = m.rollback_sqlite { + tracing::warn!( + "Rolling back SQLite migration v{}: {}", + m.version, + m.description + ); + sqlx::query(rollback).execute(pool).await?; + delete_migration(pool, m.version).await?; + tracing::info!("SQLite migration v{} rolled back", m.version); + } else { + tracing::warn!( + "Migration v{} has no rollback SQL, cannot rollback past this version", + m.version + ); + return Err(StoreError::Database(format!( + "Migration v{} has no rollback SQL", + m.version + ))); + } + } + } + Ok(()) +} + +/// 检查是否已有数据表(用于自动检测旧版 schema 版本) +async fn has_existing_tables(pool: &SqlitePool) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('sessions', 'messages', 'api_keys')", + ) + .fetch_one(pool) + .await + .unwrap_or(0); + Ok(count > 0) +} + +// ══════════════════════════════════════════════════════════════════ +// PostgreSQL 迁移函数 +// ══════════════════════════════════════════════════════════════════ + +/// 获取 PostgreSQL 当前 schema 版本 +pub async fn get_current_version_pg(pool: &PgPool) -> Result { + let result: Result, _> = + sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM _schema_version") + .fetch_one(pool) + .await; + + match result { + Ok(Some(v)) => Ok(v), + Ok(None) => Ok(0), + Err(_) => Ok(0), + } +} + +/// 记录已应用的 PostgreSQL 迁移 +async fn record_migration_pg( + pool: &PgPool, + version: i64, + description: &str, +) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO _schema_version (version, applied_at, description) VALUES ($1, $2, $3)", + ) + .bind(version) + .bind(Utc::now().timestamp_millis()) + .bind(description) + .execute(pool) + .await?; + Ok(()) +} + +/// 删除 PostgreSQL 迁移记录 +async fn delete_migration_pg(pool: &PgPool, version: i64) -> Result<(), StoreError> { + sqlx::query("DELETE FROM _schema_version WHERE version = $1") + .bind(version) + .execute(pool) + .await?; + Ok(()) +} + +/// 运行所有未执行的 PostgreSQL 前向迁移(带 `pg_advisory_lock` 互斥) +pub async fn run_migrations_pg(pool: &PgPool) -> Result<(), StoreError> { + // 获取应用级互斥锁,防止多实例竞争迁移 + // 锁 ID: 0xEASYBOT_SCHEMA_MIGRATION = 1145258561 + sqlx::query("SELECT pg_advisory_lock(1145258561)") + .execute(pool) + .await + .ok(); + + let result = run_migrations_pg_inner(pool).await; + + sqlx::query("SELECT pg_advisory_unlock(1145258561)") + .execute(pool) + .await + .ok(); + + result +} + +async fn run_migrations_pg_inner(pool: &PgPool) -> Result<(), StoreError> { + // 1. 确保版本追踪表存在 + sqlx::query(VERSION_TABLE_SQL).execute(pool).await?; + + // 2. 检查是否已有数据表但无版本记录 + let current = get_current_version_pg(pool).await?; + if current == 0 && has_existing_tables_pg(pool).await? { + sqlx::query( + "INSERT INTO _schema_version (version, applied_at, description) VALUES ($1, $2, $3)", + ) + .bind(1_i64) + .bind(Utc::now().timestamp_millis()) + .bind("Initial schema (auto-detected)") + .execute(pool) + .await?; + tracing::info!("Auto-detected existing PostgreSQL schema as v1"); + return Ok(()); + } + + // 3. 逐版执行 + for m in MIGRATIONS { + if m.version > current { + tracing::info!( + "Running PostgreSQL migration v{}: {}", + m.version, + m.description + ); + sqlx::query(m.sql_postgres).execute(pool).await?; + record_migration_pg(pool, m.version, m.description).await?; + tracing::info!("PostgreSQL migration v{} applied", m.version); + } + } + + Ok(()) +} + +/// 回滚 PostgreSQL schema 到指定版本(带锁) +pub async fn rollback_to_pg(pool: &PgPool, target_version: i64) -> Result<(), StoreError> { + sqlx::query("SELECT pg_advisory_lock(1145258561)") + .execute(pool) + .await + .ok(); + + let result = rollback_to_pg_inner(pool, target_version).await; + + sqlx::query("SELECT pg_advisory_unlock(1145258561)") + .execute(pool) + .await + .ok(); + + result +} + +async fn rollback_to_pg_inner(pool: &PgPool, target_version: i64) -> Result<(), StoreError> { + let current = get_current_version_pg(pool).await?; + if target_version >= current { + return Err(StoreError::Database( + "Target version is not older than current".into(), + )); + } + + for m in MIGRATIONS.iter().rev() { + if m.version > target_version && m.version <= current { + if let Some(rollback) = m.rollback_postgres { + tracing::warn!( + "Rolling back PostgreSQL migration v{}: {}", + m.version, + m.description + ); + sqlx::query(rollback).execute(pool).await?; + delete_migration_pg(pool, m.version).await?; + tracing::info!("PostgreSQL migration v{} rolled back", m.version); + } else { + tracing::warn!( + "Migration v{} has no rollback SQL, cannot rollback past this version", + m.version + ); + return Err(StoreError::Database(format!( + "Migration v{} has no rollback SQL", + m.version + ))); + } + } + } + Ok(()) +} + +/// 检查 PostgreSQL 是否已有数据表 +async fn has_existing_tables_pg(pool: &PgPool) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name IN ('sessions', 'messages', 'api_keys')", + ) + .fetch_one(pool) + .await + .unwrap_or(0); + Ok(count > 0) +} + +// ══════════════════════════════════════════════════════════════════ +// 测试 +// ══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + /// 创建测试用 SQLite 内存数据库 + async fn create_test_pool() -> SqlitePool { + SqlitePool::connect(":memory:") + .await + .expect("Failed to create in-memory SQLite pool") + } + + #[tokio::test] + async fn test_migration_forward() { + let pool = create_test_pool().await; + + // 空库 → 运行迁移 → 版本应为 1 + run_migrations(&pool).await.unwrap(); + let version = get_current_version(&pool).await.unwrap(); + assert_eq!(version, 1, "After migration, schema version should be 1"); + + // 验证表存在 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('sessions', 'messages', 'api_keys', '_schema_version')") + .fetch_one(&pool) + .await + .unwrap_or(0); + assert_eq!(count, 4, "All 4 tables should exist"); + } + + #[tokio::test] + async fn test_migration_idempotent() { + let pool = create_test_pool().await; + + // 两次运行迁移 + run_migrations(&pool).await.unwrap(); + run_migrations(&pool).await.unwrap(); + + let version = get_current_version(&pool).await.unwrap(); + assert_eq!(version, 1, "Idempotent: version should still be 1"); + } + + #[tokio::test] + async fn test_rollback_and_reapply() { + let pool = create_test_pool().await; + + // 前向迁移 + run_migrations(&pool).await.unwrap(); + assert_eq!(get_current_version(&pool).await.unwrap(), 1); + + // 验证 sessions 表存在 + let has_sessions: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", + ) + .fetch_one(&pool) + .await + .unwrap_or(false); + assert!(has_sessions, "sessions table should exist after migration"); + + // 回滚到 v0 + rollback_to(&pool, 0).await.unwrap(); + assert_eq!(get_current_version(&pool).await.unwrap(), 0); + + // 验证表被删除 + let has_sessions_after: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", + ) + .fetch_one(&pool) + .await + .unwrap_or(false); + assert!( + !has_sessions_after, + "sessions table should be gone after rollback" + ); + + // 再次前向迁移 + run_migrations(&pool).await.unwrap(); + assert_eq!(get_current_version(&pool).await.unwrap(), 1); + } + + #[tokio::test] + async fn test_auto_detect_existing_schema() { + let pool = create_test_pool().await; + + // 模拟旧版系统:手动创建 sessions 表(无 _schema_version 表) + sqlx::query("CREATE TABLE sessions (key TEXT PRIMARY KEY, platform TEXT NOT NULL, chat_id TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, source_json TEXT NOT NULL, reset_policy TEXT NOT NULL, metadata TEXT NOT NULL DEFAULT '{}')") + .execute(&pool) + .await + .unwrap(); + + // 运行新版迁移 → 应自动识别旧 schema 为 v1 + run_migrations(&pool).await.unwrap(); + let version = get_current_version(&pool).await.unwrap(); + assert_eq!(version, 1, "Should auto-detect existing schema as v1"); + } + + #[tokio::test] + async fn test_rollback_to_non_existent_version() { + let pool = create_test_pool().await; + run_migrations(&pool).await.unwrap(); + + // 回滚到相同版本应报错 + let result = rollback_to(&pool, 1).await; + assert!(result.is_err(), "Rollback to same version should fail"); + } +} diff --git a/crates/easybot-core/src/storage/mod.rs b/crates/easybot-core/src/storage/mod.rs index 656ebe4..ba4e399 100644 --- a/crates/easybot-core/src/storage/mod.rs +++ b/crates/easybot-core/src/storage/mod.rs @@ -5,6 +5,7 @@ //! SessionManager 使用 SessionStore 做持久化写入; //! MessageStore 用于消息历史存储。 +pub mod migration; pub mod postgres; pub mod retention; pub mod sqlite; diff --git a/crates/easybot-core/src/storage/postgres.rs b/crates/easybot-core/src/storage/postgres.rs index 32214ae..e020347 100644 --- a/crates/easybot-core/src/storage/postgres.rs +++ b/crates/easybot-core/src/storage/postgres.rs @@ -9,43 +9,6 @@ use sqlx::PgPool; use super::{MessageFilter, MessageRole, MessageStore, SessionStore, StoreError, StoredMessage}; use crate::types::session::{ResetPolicy, Session, SessionFilter, SessionSource}; -// ── Schema ── - -const SCHEMA_SQL: &str = " -CREATE TABLE IF NOT EXISTS sessions ( - key VARCHAR(255) PRIMARY KEY, - platform VARCHAR(64) NOT NULL, - chat_id VARCHAR(255) NOT NULL, - thread_id VARCHAR(255), - created_at BIGINT NOT NULL, - updated_at BIGINT NOT NULL, - source_json TEXT NOT NULL, - reset_policy VARCHAR(32) NOT NULL, - metadata JSONB NOT NULL DEFAULT '{}', - last_message TEXT, - last_message_at BIGINT -); - -CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform); -CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); - -CREATE TABLE IF NOT EXISTS messages ( - id VARCHAR(255) PRIMARY KEY, - session_key VARCHAR(255) NOT NULL, - platform VARCHAR(64) NOT NULL, - chat_id VARCHAR(255) NOT NULL, - role VARCHAR(16) NOT NULL, - text TEXT, - raw_data JSONB NOT NULL, - timestamp BIGINT NOT NULL, - created_at BIGINT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_messages_sk ON messages(session_key, timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_messages_pc ON messages(platform, chat_id, timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_messages_ct ON messages(created_at); -"; - // ── 连接与迁移 ── /// 创建 PostgreSQL 连接池 @@ -62,10 +25,11 @@ pub async fn create_pool( Ok(pool) } -/// 运行数据库迁移(幂等) +/// 运行数据库迁移(版本化,带 `pg_advisory_lock` 互斥) +/// +/// 从旧版幂等 CREATE TABLE 升级为版本化增量迁移。 pub async fn run_migrations(pool: &PgPool) -> Result<(), StoreError> { - sqlx::query(SCHEMA_SQL).execute(pool).await?; - Ok(()) + crate::storage::migration::run_migrations_pg(pool).await } // ── Session 行类型 ── diff --git a/crates/easybot-core/src/storage/sqlite.rs b/crates/easybot-core/src/storage/sqlite.rs index 1d2ab81..c61a3d3 100644 --- a/crates/easybot-core/src/storage/sqlite.rs +++ b/crates/easybot-core/src/storage/sqlite.rs @@ -12,55 +12,13 @@ use crate::types::session::{ResetPolicy, Session, SessionFilter, SessionSource}; // ── Schema ── -const SCHEMA_SQL: &str = " -CREATE TABLE IF NOT EXISTS sessions ( - key TEXT PRIMARY KEY, - platform TEXT NOT NULL, - chat_id TEXT NOT NULL, - thread_id TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - source_json TEXT NOT NULL, - reset_policy TEXT NOT NULL, - metadata TEXT NOT NULL DEFAULT '{}', - last_message TEXT, - last_message_at INTEGER -); - -CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform); -CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); - -CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - session_key TEXT NOT NULL, - platform TEXT NOT NULL, - chat_id TEXT NOT NULL, - role TEXT NOT NULL, - text TEXT, - raw_data TEXT NOT NULL, - timestamp INTEGER NOT NULL, - created_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_messages_sk ON messages(session_key, timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_messages_pc ON messages(platform, chat_id, timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_messages_ct ON messages(created_at); - -CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - prefix TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER, - last_used_at INTEGER, - revoked INTEGER NOT NULL DEFAULT 0, - permissions TEXT NOT NULL DEFAULT '[]', - event_filters TEXT NOT NULL DEFAULT '[]', - hash TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_api_keys_created ON api_keys(created_at DESC); -"; +/// 运行数据库迁移(版本化) +/// +/// 从旧版幂等 CREATE TABLE 升级为版本化增量迁移。 +/// 调用 `migration::run_migrations()` 逐版执行并追踪版本。 +pub async fn run_migrations(pool: &SqlitePool) -> Result<(), StoreError> { + crate::storage::migration::run_migrations(pool).await +} // ── 连接与迁移 ── @@ -162,12 +120,6 @@ pub async fn create_shared_pool(db_path: &std::path::Path) -> Result Result<(), StoreError> { - sqlx::query(SCHEMA_SQL).execute(pool).await?; - Ok(()) -} - // ── Session 行类型 ── /// 会话行(用于 sqlx 反序列化) diff --git a/docs/other/auto-update-tasks.md b/docs/other/auto-update-tasks.md new file mode 100644 index 0000000..42a8dfc --- /dev/null +++ b/docs/other/auto-update-tasks.md @@ -0,0 +1,160 @@ +# 自动更新功能 — 实施任务列表 + +> **归档位置**: `docs/other/upgrade-strategy.md`(设计文档) +> +> 任务来源 design doc,按实施阶段拆分。每个任务为单个 PR 可处理的粒度(适合 Flash 模型)。 + +--- + +## Phase 0: 前置依赖 + +这些任务需要在升级功能开始前完成(优化现有代码结构): + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| 0.1 | 提取 `db_path` 为 `EasyBotPaths` 公共字段 | `core/src/config/home.rs` | 后续迁移模块需引用 DB 路径 | — | +| 0.2 | 导出 `run_migrations()` 到独立函数 | `core/src/storage/sqlite.rs` | 当前嵌入在 main.rs 逻辑中 | — | + +## Phase 1: 版本化迁移系统 + +数据库 schema 从"幂等全量建表"改造为"版本化增量迁移"。 + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **1.1** | 新建 `migration.rs` 定义迁移引擎核心 | `core/src/storage/migration.rs` | `Migration` struct, `SCHEMA_VERSION` 常量, `MIGRATIONS` 数组 | — | +| **1.2** | 实现 `_schema_version` 表创建与查询 | `core/src/storage/migration.rs` | `create_version_table()`, `get_current_version()`, `record_migration()` | 1.1 | +| **1.3** | 将现有 SQLite schema 提取为 v1 迁移脚本 | `core/src/storage/migration.rs` | 把 `sqlite.rs` 中的 `SCHEMA_SQL` 拆入 `MIGRATIONS[0]` | 1.1 | +| **1.4** | 实现前向迁移引擎 | `core/src/storage/migration.rs` | `run_migrations()` 遍历未执行迁移,逐版执行 + 记录 | 1.2, 1.3 | +| **1.5** | 实现反向迁移引擎(回滚) | `core/src/storage/migration.rs` | `rollback_to(version)` 逆序遍历,执行 `rollback_sql` | 1.4 | +| **1.6** | SQLite 迁移集成(替换旧 `run_migrations`) | `core/src/storage/sqlite.rs` | 将 `sqlite::run_migrations()` 委托给 `migration::run_migrations()` | 1.4 | +| **1.7** | PostgreSQL 迁移集成 + `pg_advisory_lock` 互斥 | `core/src/storage/postgres.rs` | 同上 + 多实例竞争保护 | 1.4 | +| **1.8** | 启动时 schema 版本校验 | `bin/src/main.rs` | 存储初始化后检查 `db_version == SCHEMA_VERSION`,不匹配则拒绝启动 | 1.6, 1.7 | +| **1.9** | 迁移系统单元测试 | `core/src/storage/migration.rs` | `test_migration_roundtrip`(前向→回滚→前向)、`test_empty_db`、`test_upgrade_from_v0` | 1.5 | + +## Phase 2: 核心 updater 模块 + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **2.1** | 定义 updater 数据结构与错误类型 | `core/src/updater/types.rs` | `UpdateInfo`, `UpdatePlan`, `UpdateResult`, `UpdateError` 枚举(含所有失败场景) | — | +| **2.2** | 实现目标平台检测 | `core/src/updater/types.rs` | `current_target_triple()` 映射 `ARCH + OS` → GitHub asset 名 | 2.1 | +| **2.3** | 实现语义版本比较 | `core/src/updater/types.rs` | `is_newer_than(a, b)`, `cmp_versions()` | 2.1 | +| **2.4** | 实现预检:磁盘空间检查 | `core/src/updater/precheck.rs` | `check_disk_space()` — 获取 `available_space()` vs 3× 当前二进制大小 | — | +| **2.5** | 实现预检:权限检查 | `core/src/updater/precheck.rs` | `check_permissions()` — 测试二进制目录可写 | — | +| **2.6** | 实现预检:Docker/Dev 模式检测 | `core/src/updater/precheck.rs` | `detect_environment()` — Docker `/dockerenv`、dev 路径识别 | — | +| **2.7** | 实现预检:插件 ABI 兼容性 | `core/src/updater/precheck.rs` | `check_plugin_compatibility()` — 扫描已安装插件并比对 ABI 版本 | — | +| **2.8** | 实现 GitHub API 客户端 | `core/src/updater/github.rs` | `GitHubClient`:`latest_release()`, `version_manifest()`, `checksums()`, 缓存 + 速率限制处理 | 2.1 | +| **2.9** | 实现二进制下载 + SHA256 校验 | `core/src/updater/download.rs` | `download_verify()` — 流式下载到临时路径,用 `sha2` 计算哈希并与 `checksums.txt` 比对 | 2.8 | +| **2.10** | 实现备份管理 | `core/src/updater/compact.rs` | `BackupManager`:`create_backup()`(二进制 + SQLite + config)、`UpdateManifest` 读写、`restore_all()` | — | +| **2.11** | 实现服务单元路径更新 | `core/src/updater/compact.rs` | `update_service_bin_path()` — 检测 systemd/launchd 并更新 `ExecStart`/`ProgramArguments` | — | +| **2.12** | 实现二进制替换 + 回滚 | `core/src/updater/replace.rs` | `replace_binary()` — 备份→暂存→原子 rename→权限设置;`rollback_binary()` — 从 backup 恢复 | 2.10 | +| **2.13** | 实现完整更新流程编排 | `core/src/updater/mod.rs` | `Updater` struct:`check_update()` → `UpdatePlan`,`perform_update()` → 预检→备份→下载→替换→迁移→验证→清理 | 2.1-2.12 | +| **2.14** | 注册 updater 模块 | `core/src/lib.rs` | 添加 `pub mod updater;` | 2.13 | + +## Phase 3: CLI 集成 + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **3.1** | CLI 增加子命令枚举 | `bin/src/main.rs` | `Commands` enum: `CheckUpdate`, `Update`, `Rollback` | 2.13 | +| **3.2** | 实现 `check-update` 处理函数 | `bin/src/main.rs` | `handle_check_update()` — 调用 `Updater::check_update()`,打印格式化的版本和迁移信息 | 3.1 | +| **3.3** | 实现 `update` 处理函数 | `bin/src/main.rs` | `handle_update()` — 预检 → 确认 → 执行完整更新流程 + 重启指引 | 3.1 | +| **3.4** | 实现 `rollback` 处理函数 | `bin/src/main.rs` | `handle_rollback()` — 读取 manifest → 恢复二进制 → 回滚 DB → 恢复 config | 3.1 | +| **3.5** | `main()` 入口分派逻辑 | `bin/src/main.rs` | 匹配子命令 → 分别调用 handle_* → 网关逻辑仅在无子命令时启动 | 3.2-3.4 | + +## Phase 4: GitHub Release Workflow + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **4.1** | 增加 `easybot-version.json` 生成步骤 | `.github/workflows/release.yml` | `prepare-release` job 末尾用 `jq` 生成版本元数据 JSON | — | +| **4.2** | 将 `easybot-version.json` 上传为 release asset | `.github/workflows/release.yml` | `create-release` 步骤的 `files:` 中包含该文件 | 4.1 | + +## Phase 5: API 端点 + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **5.1** | 创建 `update-check` API 路由 | `api/src/routes/update.rs` | `GET /api/v1/system/update-check`,返回当前版本 + 最新版本 + schema 版本 + 更新状态 | 2.13 | +| **5.2** | 注册路由、权限和 OpenAPI | `api/src/server.rs`, `api/src/openapi.rs` | 添加到 protected_routes + 权限中间件 + OpenAPI 路径列表 | 5.1 | +| **5.3** | health 端点增加 `schema_version` | `api/src/routes/health.rs` | `HealthResponse` 增加 `schema_version` 字段 | — | + +## Phase 6: 测试 + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **6.1** | 迁移系统测试 | `core/src/storage/migration.rs` | `test_migration_forward`, `test_migration_backward`, `test_rollback_to`, `test_idempotent` | 1.5 | +| **6.2** | updater 单元测试 | `core/src/updater/types.rs` | `test_version_comparison`, `test_target_triple` | 2.3, 2.2 | +| **6.3** | 下载 + SHA256 测试 | `core/src/updater/download.rs` | `test_sha256_match`, `test_sha256_mismatch`, `test_parse_checksums` | 2.9 | +| **6.4** | 备份/恢复测试 | `core/src/updater/replace.rs` | `test_backup_restore_cycle`(临时目录中模拟) | 2.12 | +| **6.5** | precheck 测试 | `core/src/updater/precheck.rs` | `test_disk_space_check`, `test_env_detection` | 2.4-2.7 | +| **6.6** | GitHub API mock 测试 | `core/src/updater/github.rs` | 使用 `wiremock` 模拟 GitHub API 响应 | 2.8 | +| **6.7** | 升级 + 回滚集成测试 | `tests/integration/` | 创建模拟 release + 旧版 DB → `update` → 验证 schema → `rollback` → 验证 schema 回退 | 3.1-3.5 | +| **6.8** | API 端点测试 | `api/tests/` | 验证 `update-check` 返回结构、健康端点 schema_version | 5.1 | + +## Phase 7: 文档同步 + +| # | 任务 | 文件 | 说明 | 依赖 | +|---|------|------|------|------| +| **7.1** | 更新 `upgrade-strategy.md` | `docs/other/upgrade-strategy.md` | 根据实现实际情况修正设计文档 | 全部 | +| **7.2** | 更新 `CLAUDE.md` 项目指引 | `CLAUDE.md` | 添加 migration/updater 模块说明、任务拆分参考 | — | +| **7.3** | 更新 `user-guide.md` | `docs/01 user-guide.md` | 添加升级命令章节(check-update, update, rollback) | 3.5 | + +--- + +## 实施顺序 + +``` +Phase 1 (迁移引擎) + 1.1 → 1.2 → 1.3 → 1.4 → 1.5 → 1.6 → 1.7 → 1.8 → 1.9 + │ + └────────── 可以并行测试 ──────────┘ + +Phase 2 (updater 模块) + 2.1 → 2.2/2.3 ──→ 2.4/2.5/2.6/2.7 ──→ 2.8 → 2.9 + │ + 2.10 → 2.11 → 2.12 ─┤ + ▼ + 2.13 → 2.14 + │ + └── 2.4-2.7(预检)可并行开发 ──┘ + +Phase 3 (CLI) ← 需要 2.13 + └── 3.1 → 3.2 → 3.3 → 3.4 → 3.5 + +Phase 4 (Workflow) ← 可独立于 Phase 2/3 开发 + └── 4.1 → 4.2 + +Phase 5 (API) ← 需要 2.13 + └── 5.1 → 5.2 → 5.3 + +Phase 6 (Test) ← 依赖各 Phase 完成 + └── 可并行开发:6.1/6.2/6.3/6.4/6.5/6.6/6.7/6.8 + +Phase 7 (文档) ← 最后 + └── 7.1 → 7.2 → 7.3 +``` + +## 文件映射(任务→文件) + +``` +core/src/storage/migration.rs → 1.1, 1.2, 1.3, 1.4, 1.5, 1.9, 6.1 +core/src/storage/sqlite.rs → 1.6 +core/src/storage/postgres.rs → 1.7 +core/src/storage/mod.rs → 注册 migration 模块 +core/src/updater/mod.rs → 2.13 +core/src/updater/types.rs → 2.1, 2.2, 2.3, 6.2 +core/src/updater/precheck.rs → 2.4, 2.5, 2.6, 2.7, 6.5 +core/src/updater/github.rs → 2.8, 6.6 +core/src/updater/download.rs → 2.9, 6.3 +core/src/updater/compact.rs → 2.10, 2.11 +core/src/updater/replace.rs → 2.12, 6.4 +core/src/lib.rs → 2.14 +bin/src/main.rs → 1.8, 3.1, 3.2, 3.3, 3.4, 3.5 +api/src/routes/update.rs → 5.1 +api/src/routes/health.rs → 5.3 +api/src/server.rs → 5.2 +api/src/openapi.rs → 5.2 +.github/workflows/release.yml → 4.1, 4.2 +tests/integration/src/ → 6.7 +api/tests/ → 6.8 +docs/other/upgrade-strategy.md → 7.1 +CLAUDE.md → 7.2 +docs/01 user-guide.md → 7.3 +``` diff --git a/docs/other/upgrade-strategy.md b/docs/other/upgrade-strategy.md new file mode 100644 index 0000000..c340632 --- /dev/null +++ b/docs/other/upgrade-strategy.md @@ -0,0 +1,670 @@ +# 版本升级与自动更新方案 + +> **设计文档** — 描述 Rust 二进制应用的自动更新架构设计,涵盖二进制替换、数据库迁移、配置迁移、回滚等完整升级流程。 +> +> 本文档可作为其他类似项目的参考。 + +--- + +## 1. 背景与目标 + +### 1.1 问题 + +随着版本迭代推进,用户在从旧版本升级到新版本时,需要完成一系列繁琐的手动操作。一个完整的升级远不止替换二进制文件——它还涉及数据库 schema 迁移、配置格式适配、插件兼容性验证、服务管理器更新等多个层面。 + +### 1.2 目标 + +- 提供 `easybot update` 一键升级命令 +- 支持安全回滚(`easybot rollback`) +- 覆盖所有部署场景(裸机二进制、systemd/launchd 服务、Docker 容器) +- 更新前可预览影响范围(`easybot check-update`) +- 更新过程可审计、可中断、可恢复 + +### 1.3 非目标 + +- 不实现零宕机滚动升级(当前架构为单进程 daemon) +- 不自动重启服务(由用户/服务管理器决定何时重启) +- 不提供插件自动重新编译(插件由第三方维护) + +--- + +## 2. 升级涉及的所有层面 + +版本升级的核心挑战在于:**替换二进制只是起点**。一个安全的升级系统必须审计以下 8 个层面: + +``` +二进制 (binary) + └─ 可执行文件替换(OS 差异、原子性、权限) + +数据库 Schema (schema) + ├─ 表结构增减(CREATE TABLE / ALTER TABLE) + ├─ 索引变更 + └─ 数据约束变化 + +数据库数据 (data) + ├─ 数据迁移(列拆分、类型转换、默认值填充) + └─ 数据完整性(外键、唯一约束) + +配置 (config) + ├─ 配置字段新增/删除/重命名 + └─ 配置结构重组(嵌套层级变化) + +插件兼容性 (plugin) + ├─ Plugin ABI 版本 + └─ Plugin SDK 接口变更 + +服务管理器 (service manager) + ├─ systemd unit 中的 ExecStart 硬编码路径 + ├─ launchd plist 中的 ProgramArguments + └─ Windows 服务的 binPath + +API/观测面 (observability) + ├─ API 响应格式变化(字段新增/删除/重命名) + ├─ Prometheus metric 标签变化 + └─ 日志格式变化 + +升级自身安全 (safety) + ├─ 磁盘空间不足 + ├─ 权限不足(二进制被 root 拥有) + ├─ 网络不可达(离线环境) + ├─ 多实例竞争迁移 + ├─ 降级保护 + └─ 升级跳版本过多 +``` + +--- + +## 3. 总体架构 + +### 3.1 核心流程 + +``` +用户: easybot check-update + │ + ▼ +┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ Pre-Check │───▶│ GitHub API 检测 │───▶│ UpdatePlan │ +│ 磁盘/权限/网 │ │ 版本/迁移清单 │ │ 完整预览 │ +│ 络/插件/Docker│ │ checksums.txt │ │ │ +└─────────────┘ └──────────────────┘ └─────────────┘ + │ + ▼ 用户确认 + ┌────────────────┐ + │ Backup Phase │ + │ 二进制 + DB + │ + │ config + manifest│ + └────────────────┘ + │ + ▼ + ┌────────────────┐ + │ Apply Phase │ + │ 下载 → SHA256→ │ + │ 替换 → 迁移 → │ + │ 服务更新 │ + └────────────────┘ + │ + ▼ + ┌────────────────┐ + │ Verify Phase │ + │ 验证新二进制 → │ + │ 成功: 清理备份 │ + │ 失败: 自动回滚 │ + └────────────────┘ + │ + ▼ + 提示用户重启服务 +``` + +### 3.2 状态流转 + +``` + ┌──────────┐ + │ 就绪 │ + └────┬─────┘ + │ easybot update + ▼ + ┌──────────┐ + │ 预检中 │ ← 磁盘/权限/网络/插件检查 + └────┬─────┘ + │ 通过 + ▼ + ┌──────────┐ + │ 计划中 │ ← GitHub API + 生成 UpdatePlan + └────┬─────┘ + │ 用户确认 + ▼ + ┌──────────┐ + │ 备份中 │ ← 二进制 + DB + config + └────┬─────┘ + │ 成功 + ▼ + ┌──────────┐ + │ 应用中 │ ← 下载 → SHA256 → 替换 → 迁移 + └────┬─────┘ + ┌────┴────┐ + │ │ + ▼ ▼ + ┌────────┐ ┌──────────┐ + │ 验证中 │ │ 自动回滚 │ ← 任何失败 + └────┬───┘ └────┬─────┘ + │ 成功 │ + ▼ ▼ + ┌────────┐ ┌──────────┐ + │ 已完成 │ │ 回滚完成 │ + └────────┘ └──────────┘ +``` + +### 3.3 回滚流程 + +``` +easybot rollback + │ + ▼ +读取 .update_manifest.json(备份清单) + │ + ▼ +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 停止服务 │───▶│ 恢复二进制│───▶│ 回滚 DB │ +└──────────┘ └──────────┘ └──────────┘ + │ + ▼ + ┌──────────────┐ + │ 恢复 config │ + └──────┬───────┘ + ▼ + ┌──────────────┐ + │ 启动服务 │ + │ 清除 manifest│ + └──────────────┘ +``` + +--- + +## 4. 数据模型 + +### 4.1 版本声明(编译时嵌入) + +每个二进制版本在编译时声明其所需的 schema 版本和插件 ABI 版本: + +```rust +// crates/easybot-core/src/storage/migration.rs +/// 二进制所期望的数据库 schema 版本 +pub const SCHEMA_VERSION: i64 = 1; + +/// 已注册的所有迁移脚本 +pub static MIGRATIONS: &[Migration] = &[ + Migration { version: 1, description: "...", sql: V1_SQL, rollback_sql: Some(V1_RB) }, +]; +``` + +### 4.2 数据库 Schema 版本表 + +```sql +CREATE TABLE IF NOT EXISTS _schema_version ( + version INTEGER NOT NULL, + applied_at INTEGER NOT NULL, + description TEXT NOT NULL +); +``` + +### 4.3 版本发布清单(Release asset) + +```json +{ + "version": "0.1.0", + "tag": "v0.1.0", + "release_date": "2026-07-21", + "schema_version": 2, + "requires_db_migration": true, + "migrations": [ + { "version": 2, "description": "Add webhook_url column to sessions" } + ], + "requires_config_migration": false, + "config_changes": [], + "breaking_changes": [ + "The /api/v1/messages endpoint now requires 'platform' parameter" + ], + "plugin_abi_version": 1, + "min_upgradable_from": "0.0.10" +} +``` + +### 4.4 更新备份清单 + +```json +{ + "timestamp": 1721571200000, + "from_version": "0.0.16", + "to_version": "0.1.0", + "from_schema_version": 1, + "to_schema_version": 2, + "binary_backup": "/usr/local/bin/easybot.bak.0.0.16", + "db_backup": "/home/easybot/.easybot/data/gateway.db.bak.0.0.16", + "config_backup": "/home/easybot/.easybot/gateway.yaml.bak.0.0.16", + "migrations_applied": [2] +} +``` + +### 4.5 更新计划(用户预览) + +```rust +pub struct UpdatePlan { + pub current_version: String, // v0.0.16 + pub target_version: String, // v0.1.0 + pub target_schema_version: i64, // 2 + pub current_schema_version: i64, // 1 + pub requires_db_migration: bool, // true + pub db_migrations: Vec, // [{v2, "Add webhook_url"}] + pub requires_config_migration: bool, + pub config_changes: Vec, + pub breaking_changes: Vec, + pub plugin_incompatible: Vec, + pub binary_size: u64, + pub checksum: String, + pub requires_service_update: bool, // systemd ExecStart 需更新 +} +``` + +--- + +## 5. 关键设计决策 + +### 5.1 使用 GitHub Releases API 而非独立更新服务器 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| ✅ GitHub Releases API | 零运维、与 CI 合一、已有 assets | 速率限制(60/h 未认证) | +| ❌ 独立更新服务器 | 无速率限制、可私有化部署 | 额外运维成本 | +| ❌ S3/对象存储 | 下载快 | 需要签名、额外成本 | + +**结论**:GitHub Releases API + 可选 `GITHUB_TOKEN` 提升速率限制。 + +### 5.2 自定义实现 vs `self_update` crate + +| 方案 | 优点 | 缺点 | +|------|------|------| +| ✅ 自定义(reqwest + sha2) | 轻量、全可控、无额外传递依赖 | 需要编写更多代码 | +| ❌ `self_update` crate | 开箱即用、成熟 | 依赖重(zip/tar/gz 等)、不符合 EasyBot 发布模式 | + +**结论**:自定义实现。EasyBot 发布的是纯二进制(非压缩包),`self_update` 的 archive 处理能力是冗余的。已有 `reqwest` + `sha2` 依赖。 + +### 5.3 版本化增量迁移 vs 全量幂等脚本 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| ✅ 版本化增量迁移 | 可追溯、可控、可回滚、可跳过已执行 | 需要 schema 版本表 | +| ❌ 全量幂等 `CREATE TABLE IF NOT EXISTS` | 简单 | 不支持 `ALTER TABLE`、无回滚 | + +**结论**:将现有全量迁移改造为版本化增量迁移。首次迁移系统记录现有 schema 为 v1。 + +### 5.4 嵌入迁移脚本 vs 外部 SQL 文件 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| ✅ 嵌入二进制(`const &str`) | 版本与二进制锁定、无文件丢失 | 大 SQL 增加二进制体积 | +| ❌ 外部 SQL 文件(SQLite 迁移目录) | 迁移文件可独立管理 | 路径问题、版本与二进制可能不一致 | + +**结论**:嵌入二进制。迁移 SQL 通常很小(KB 级别),版本锁定更安全。 + +--- + +## 6. 安全性设计 + +### 6.1 传输安全 + +- 所有下载通过 HTTPS(`reqwest` 默认使用 rustls) +- GitHub API 强制 HTTPS + +### 6.2 完整性校验 + +``` +GitHub Release + ├── easybot-x86_64-unknown-linux-musl (二进制) + ├── checksums.txt (SHA256 校验和) + └── easybot-version.json (版本元数据) + +校验流程: + github.rs → 获取 checksums.txt + download.rs → 流式下载二进制 + verify.rs → SHA256(二进制) == checksums.txt[文件名] + replace.rs → 仅在校验通过后才替换 +``` + +### 6.3 原子替换与回滚 + +```rust +fn replace_binary(new_bin: &Path, current: &Path) -> Result { + // 1. 备份 + std::fs::copy(current, backup)?; + // 2. 暂存(同文件系统,确保 rename 原子性) + std::fs::rename(new_bin, temp)?; + // 3. 原子替换 + std::fs::rename(temp, current)?; + // 4. 如失败 → 从 backup 恢复 + // 5. 如成功 → 删除 backup +} +``` + +### 6.4 降级保护 + +- 仅允许 `semver::Version::newer_than`(新版本 > 当前版本) +- 启动时:如果 `DB.schema_version > binary.SCHEMA_VERSION` → 拒绝启动 +- 如需要降级:`easybot rollback [version]` + +### 6.5 互斥迁移 + +| 数据库 | 机制 | +|--------|------| +| SQLite | `BEGIN IMMEDIATE` 事务 | +| PostgreSQL | `pg_advisory_lock(1145258561)` | + +--- + +## 7. 错误处理策略 + +### 7.1 预检失败 + +所有预检失败在 Phase 1 中提前报出,不产生任何副作用: + +| 失败原因 | 提示信息 | +|---------|---------| +| 磁盘空间不足 | "需要 xxx MB 可用空间,当前仅 xxx MB" | +| 权限不足 | "无权写入 {path},请使用 sudo 或以 root 执行" | +| 运行在 Docker 中 | "Docker 用户请使用 docker compose pull && up -d" | +| 运行在开发模式 | "开发模式不支持自动更新" | +| GitHub API 不可达 | "离线模式:无法检查更新。使用 --offline 从本地文件更新" | +| 插件不兼容 | "以下插件与新版本不兼容: [列表]。使用 --skip-plugin-check 强制更新" | + +### 7.2 备份失败 + +备份失败的恢复策略: + +```rust +// 备份分阶段进行,早期失败不产生副作用 +fn create_backups() -> Result { + // 每个备份独立进行,已成功的备份在失败时回退 + let bin_backup = backup_binary().ok(); + let db_backup = backup_database().ok(); + let cfg_backup = backup_config()?; // config 是最重要的,失败则中止 + + if bin_backup.is_none() || db_backup.is_none() { + // 警告但不中止:某些备份失败(如 SQLite 不可用),但仍可继续 + } + Ok(UpdateManifest { + binary_backup: bin_backup, + db_backup, + config_backup: cfg_backup, + }) +} +``` + +### 7.3 迁移失败 + +数据库迁移失败 → 自动回滚: + +``` +migration::run_migrations() + │ + ┌─── v2: ALTER TABLE sessions ADD COLUMN webhook_url TEXT + │ ├── 成功 → 提交,写入 _schema_version + │ └── 失败 → 回滚事务,上一步的 migration 状态恢复 + │ + └─── 任何 migration 失败 → 整体失败提示 + │ + 自动回滚: replace.rs 恢复旧二进制 + 完整恢复到备份状态 +``` + +### 7.4 验证失败 + +新二进制替换后验证失败 → 全量回滚: + +```rust +match verify_new_binary(new_bin).await { + Ok(_) => { + // 清理备份 + cleanup_backup(manifest); + println!("✓ 升级完成,请重启服务"); + } + Err(e) => { + // 自动回滚 + rollback::restore_binary(&manifest).await?; + rollback::restore_database(&manifest).await?; + // 无需恢复 config(未修改) + println!("✗ 新二进制验证失败({}),已自动回滚", e); + } +} +``` + +--- + +## 8. EasyBot 实现计划 + +### 8.1 实施阶段 + +``` +Phase 1: 重构迁移系统 (2-3 天) + ├── 创建 _schema_version 表 + ├── 将现有 SCHEMA_SQL 拆分为 v1 迁移 + ├── 运行迁移时版本追踪 + ├── 启动时 schema 版本锁定 + └── PostgreSQL pg_advisory_lock 互斥 + +Phase 2: 核心 updater 模块 (2-3 天) + ├── types.rs 数据结构 + ├── github.rs API 客户端 + ├── download.rs 下载 + SHA256 + ├── replace.rs 二进制替换(含 backup/rollback) + ├── precheck.rs 预检 + └── compact.rs 备份清单 + 服务更新 + +Phase 3: CLI 集成 (1 天) + ├── check-update / update / rollback 子命令 + ├── 完整更新流程编排 + ├── 启动时 DB schema 校验 + └── 用户交互提示 + +Phase 4: Release 工作流 (0.5 天) + ├── easybot-version.json 生成 + └── workflow 中上传 + +Phase 5: API + 文档 (0.5 天) + ├── GET /api/v1/system/update-check 端点 + └── 更新用户文档 + +Phase 6: 测试 (1-2 天) + ├── 单元测试 + ├── 迁移前/后测试 + ├── 升级 + 回滚集成测试 + └── 边界测试(离线、权限不足等) +``` + +### 8.2 涉及文件 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `crates/easybot-core/src/storage/migration.rs` | **新建** | 版本化迁移引擎 | +| `crates/easybot-core/src/storage/mod.rs` | 修改 | 注册 migration 模块 | +| `crates/easybot-core/src/storage/sqlite.rs` | 修改 | `run_migrations` 委托给 migration 引擎 | +| `crates/easybot-core/src/storage/postgres.rs` | 修改 | 同上 + `pg_advisory_lock` | +| `crates/easybot-core/src/updater/mod.rs` | **新建** | 更新入口 + 流程编排 | +| `crates/easybot-core/src/updater/types.rs` | **新建** | 数据模型 | +| `crates/easybot-core/src/updater/precheck.rs` | **新建** | 预检 | +| `crates/easybot-core/src/updater/github.rs` | **新建** | GitHub API | +| `crates/easybot-core/src/updater/download.rs` | **新建** | 下载 + SHA256 | +| `crates/easybot-core/src/updater/replace.rs` | **新建** | 二进制替换 | +| `crates/easybot-core/src/updater/compact.rs` | **新建** | 备份 + 服务更新 | +| `crates/easybot-core/src/lib.rs` | 修改 | 注册 updater | +| `bin/src/main.rs` | 修改 | CLI 子命令 + schema 校验 | +| `crates/easybot-api/src/routes/update.rs` | **新建** | API 端点 | +| `crates/easybot-api/src/server.rs` | 修改 | 注册路由 | +| `crates/easybot-api/src/openapi.rs` | 修改 | OpenAPI 注册 | +| `.github/workflows/release.yml` | 修改 | 生成 version.json | +| `Cargo.toml` (workspace) | 修改 | 添加 `semver` 依赖 | + +### 8.3 分支策略 + +``` +main + │ + └── feature/auto-update ← 在此分支开发 + │ + ├── Phase 1 (migration) + ├── Phase 2 (updater) + ├── Phase 3 (CLI) + ├── Phase 4 (workflow) + └── Phase 5 (API + docs) + │ + └── merge → main (@ v0.1.0) +``` + +--- + +## 9. 扩展性考虑 + +### 9.1 被其他项目参考 + +本文档的设计模式不限于 Rust 或 EasyBot。以下框架可直接复用: + +| 设计要素 | 通用性 | +|---------|--------| +| 8 层面审计清单 | 任意语言/项目的升级风险评估 | +| Schema 版本表 + 增量迁移 | 任意需要数据库迁移的项目 | +| 迁移互斥锁 | 多实例部署的任何项目 | +| 备份清单 Manifest | 任何需要安全回滚的项目 | +| 预检机制 | 任何变更操作的边界检查 | +| 原子替换 + 3 步回滚 | 任何二进制/文件替换操作 | +| `min_upgradable_from` 跳版本保护 | 任何无法无限向后兼容的项目 | + +### 9.2 未来增强方向 + +- **Zero-downtime rolling update**:当 EasyBot 支持多实例部署时,可通过 LB 健康检查实现 +- **Watchtower 集成**:Docker 用户的自动更新 sidecar(已有 `containrrr/watchtower`) +- **Webhook 通知**:更新完成后通过 Webhook 通知管理员 +- **自动回滚监控**:升级后监控一段时间内是否有异常,自动触发回滚 +- **增量二进制下载**:使用 `bsdiff` 实现增量更新(大幅减少下载量) + +--- + +## 10. 测试策略 + +### 10.1 单元测试 + +```rust +// 版本比较 +#[test] +fn test_version_comparison() { + assert!(is_newer_than("0.1.0", "0.0.16")); + assert!(!is_newer_than("0.0.16", "0.1.0")); + assert!(!is_newer_than("0.0.16", "0.0.16")); +} + +// 校验和解析 +#[test] +fn test_parse_checksums() { + let c = parse_checksums("abc easybot-x86_64-linux\n"); + assert_eq!(c.len(), 1); + assert_eq!(c[0].0, "abc"); +} + +// 平台检测 +#[test] +fn test_target_triple() { + let t = current_target_triple(); + assert!(t.contains("linux") || t.contains("apple") || t.contains("windows")); +} + +// 迁移前/后 +#[test] +fn test_migration_roundtrip() { + // 创建空 DB → version 0 + // 运行迁移 → version 1 + // 回滚到 version 0 → 表不存 + // 再次迁移 → version 1 +} + +// 备份/恢复 +#[test] +fn test_backup_restore_cycle() { + // 创建临时二进制 + // 备份它 + // 写入新内容 + // 从备份恢复 + // 验证内容与原始一致 +} +``` + +### 10.2 集成测试 + +```rust +// 模拟 GitHub API +#[tokio::test] +async fn test_github_api_latest() { + let mock = wiremock::MockServer::start().await; + // 模拟 /repos/EasyIndie/EasyBot/releases/latest + // 验证解析正确 +} + +// 升级 + 回滚完整模拟 +#[tokio::test] +async fn test_full_update_rollback() { + // 1. 创建旧版 binary + 旧版 SQLite DB + // 2. 创建模拟的 "新版本" release + // 3. 执行 update + // 4. 验证 DB schema 升级 + // 5. 执行 rollback + // 6. 验证 DB schema 回退到旧版本 + // 7. 验证旧 binary 能正常读写 DB +} +``` + +### 10.3 手动验证清单 + +```bash +# 1. 编译 +cargo build --release + +# 2. 检查更新 +./target/release/easybot check-update + +# 3. 离线测试 +# 阻断网络 +./target/release/easybot check-update +# 预期: "离线模式,无法检查更新" + +# 4. 权限测试 +sudo chown root:root /usr/local/bin/easybot +./target/release/easybot update +# 预期: "权限不足,请使用 sudo 执行" + +# 5. Docker 检测测试 +touch /.dockerenv +./target/release/easybot update +# 预期: "Docker 用户请使用 docker compose pull" +rm /.dockerenv +``` + +--- + +## 11. 附录 + +### 11.1 术语表 + +| 术语 | 说明 | +|------|------| +| Schema | 数据库表结构定义 | +| Migration | 数据库 schema 版本间的增量变更(含前向和回滚) | +| Update | 完整的版本升级过程(二进制 + 数据库 + 配置) | +| Rollback | 恢复到前一个版本(二进制 + 数据库 + 配置) | +| Pre-check | 更新前的预检阶段(磁盘/权限/网络等) | +| Backup Manifest | 备份清单文件,记录所有备份的位置,用于回滚 | +| ABI | Application Binary Interface,插件的二进制接口版本 | +| `semver` | 语义化版本号 (`major.minor.patch`) | +| `min_upgradable_from` | 发布清单中的字段,标识从哪个版本开始可直接升级到本版本 | + +### 11.2 参考 + +- [semver.org](https://semver.org/) — 语义化版本标准 +- [GitHub Releases API](https://docs.github.com/en/rest/releases/releases) — 版本发布接口 +- [SQLite WAL mode](https://www.sqlite.org/wal.html) — 并发读写支持 +- [PostgreSQL Advisory Lock](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) — 应用级互斥锁 From 2c048a1843e3f7c5c86d57809cd2209be981af22 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Wed, 22 Jul 2026 01:57:21 +0800 Subject: [PATCH 02/11] feat: updater module with full update lifecycle --- Cargo.lock | 1 + Cargo.toml | 3 + crates/easybot-core/Cargo.toml | 1 + crates/easybot-core/src/lib.rs | 1 + crates/easybot-core/src/updater/compact.rs | 454 ++++++++++++++++++++ crates/easybot-core/src/updater/download.rs | 96 +++++ crates/easybot-core/src/updater/github.rs | 405 +++++++++++++++++ crates/easybot-core/src/updater/mod.rs | 252 +++++++++++ crates/easybot-core/src/updater/precheck.rs | 336 +++++++++++++++ crates/easybot-core/src/updater/replace.rs | 216 ++++++++++ crates/easybot-core/src/updater/types.rs | 319 ++++++++++++++ 11 files changed, 2084 insertions(+) create mode 100644 crates/easybot-core/src/updater/compact.rs create mode 100644 crates/easybot-core/src/updater/download.rs create mode 100644 crates/easybot-core/src/updater/github.rs create mode 100644 crates/easybot-core/src/updater/mod.rs create mode 100644 crates/easybot-core/src/updater/precheck.rs create mode 100644 crates/easybot-core/src/updater/replace.rs create mode 100644 crates/easybot-core/src/updater/types.rs diff --git a/Cargo.lock b/Cargo.lock index 9d02af3..7705b4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1025,6 +1025,7 @@ dependencies = [ "rand_core 0.9.5", "regex", "reqwest", + "semver", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 4edaccb..19880c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,9 @@ utoipa-swagger-ui = "9" # QR 码生成 qrcode = { version = "0.14", default-features = false } +# 语义版本比较 +semver = "1" + [profile.release] opt-level = 3 lto = "fat" diff --git a/crates/easybot-core/Cargo.toml b/crates/easybot-core/Cargo.toml index fba9b43..84722c2 100644 --- a/crates/easybot-core/Cargo.toml +++ b/crates/easybot-core/Cargo.toml @@ -34,6 +34,7 @@ reqwest.workspace = true libloading = { workspace = true, optional = true } utoipa = { workspace = true } sysinfo.workspace = true +semver.workspace = true [dev-dependencies] wiremock = "0.6" diff --git a/crates/easybot-core/src/lib.rs b/crates/easybot-core/src/lib.rs index 42db392..96f760f 100644 --- a/crates/easybot-core/src/lib.rs +++ b/crates/easybot-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod session; pub mod storage; pub mod system; pub mod types; +pub mod updater; pub mod util; pub mod webhook; diff --git a/crates/easybot-core/src/updater/compact.rs b/crates/easybot-core/src/updater/compact.rs new file mode 100644 index 0000000..f2a5d4d --- /dev/null +++ b/crates/easybot-core/src/updater/compact.rs @@ -0,0 +1,454 @@ +//! 备份管理与服务单元更新 +//! +//! 管理更新过程中的备份生命周期(创建、读取、恢复、清理), +//! 以及更新后更新系统服务(systemd/launchd)的二进制路径。 + +use super::types::{ServiceType, UpdateError, UpdateManifest}; +use std::path::{Path, PathBuf}; + +// ══════════════════════════════════════════════════════════════════ +// 备份管理 +// ══════════════════════════════════════════════════════════════════ + +/// 备份管理器:创建和恢复更新前的备份快照 +pub struct BackupManager; + +impl BackupManager { + /// 创建完整备份 + /// + /// 备份以下内容: + /// 1. 当前二进制文件 + /// 2. SQLite 数据库(如果存在) + /// 3. 配置文件(gateway.yaml) + /// + /// 所有备份路径记录到 `.update_manifest.json`。 + pub async fn create_backup( + home: &Path, + from_version: &str, + to_version: &str, + from_schema_version: i64, + to_schema_version: i64, + ) -> Result { + let timestamp = chrono::Utc::now().timestamp_millis(); + + let mut manifest = UpdateManifest { + timestamp, + from_version: from_version.to_string(), + to_version: to_version.to_string(), + from_schema_version, + to_schema_version, + binary_backup: None, + db_backup: None, + config_backup: None, + migrations_applied: Vec::new(), + }; + + // 1. 备份二进制 + if let Ok(exe) = std::env::current_exe() { + let backup_name = format!( + "{}.bak.{}", + exe.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("easybot"), + from_version + ); + let backup_path = exe.with_file_name(&backup_name); + + // 同文件系统复制 + match tokio::fs::copy(&exe, &backup_path).await { + Ok(_) => { + manifest.binary_backup = Some(backup_path.to_string_lossy().to_string()); + tracing::info!("Binary backup created: {}", backup_path.display()); + } + Err(e) => { + tracing::warn!("Failed to backup binary: {}", e); + return Err(UpdateError::BackupFailed(format!( + "Binary backup failed: {}", + e + ))); + } + } + } + + // 2. 备份数据库(SQLite) + let db_path = home.join("data").join("gateway.db"); + if db_path.exists() { + // 先执行 WAL checkpoint 确保数据一致性 + if let Some(pool) = try_get_sqlite_pool(&db_path).await { + let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&pool) + .await; + drop(pool); + } + + let db_backup = home + .join("data") + .join(format!("gateway.db.bak.{}", from_version)); + + match tokio::fs::copy(&db_path, &db_backup).await { + Ok(_) => { + manifest.db_backup = Some(db_backup.to_string_lossy().to_string()); + tracing::info!("Database backup created: {}", db_backup.display()); + } + Err(e) => { + // DB 备份失败不中止更新,仅记录警告 + tracing::warn!("Failed to backup database: {}", e); + } + } + } + + // 3. 备份配置文件 + let config_path = home.join("gateway.yaml"); + if config_path.exists() { + let config_backup = home.join(format!("gateway.yaml.bak.{}", from_version)); + match tokio::fs::copy(&config_path, &config_backup).await { + Ok(_) => { + manifest.config_backup = Some(config_backup.to_string_lossy().to_string()); + tracing::info!("Config backup created: {}", config_backup.display()); + } + Err(e) => { + tracing::warn!("Failed to backup config: {}", e); + } + } + } + + // 4. 写入备份清单 + Self::write_manifest(home, &manifest).await?; + + Ok(manifest) + } + + /// 读取备份清单 + pub async fn read_manifest(home: &Path) -> Result { + let manifest_path = home.join(".update_manifest.json"); + if !manifest_path.exists() { + return Err(UpdateError::Other( + "No update manifest found. Nothing to rollback.".into(), + )); + } + + let content = tokio::fs::read_to_string(&manifest_path) + .await + .map_err(UpdateError::IoError)?; + let manifest: UpdateManifest = serde_json::from_str(&content)?; + Ok(manifest) + } + + /// 写入备份清单 + async fn write_manifest(home: &Path, manifest: &UpdateManifest) -> Result<(), UpdateError> { + let manifest_path = home.join(".update_manifest.json"); + let content = serde_json::to_string_pretty(manifest)?; + tokio::fs::write(&manifest_path, &content) + .await + .map_err(UpdateError::IoError)?; + Ok(()) + } + + /// 恢复所有备份 + pub async fn restore_all(manifest: &UpdateManifest) -> Result<(), UpdateError> { + // 1. 恢复二进制 + if let Some(ref backup) = manifest.binary_backup { + let backup_path = Path::new(backup); + if backup_path.exists() { + let exe = std::env::current_exe().map_err(|e| { + UpdateError::BackupFailed(format!("Cannot get exe path: {}", e)) + })?; + tokio::fs::copy(backup_path, &exe).await.map_err(|e| { + UpdateError::BackupFailed(format!("Failed to restore binary: {}", e)) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(perm) = std::fs::metadata(&exe).map(|m| m.permissions()) { + let mut perm = perm; + perm.set_mode(0o755); + let _ = std::fs::set_permissions(&exe, perm); + } + } + + tracing::info!("Binary restored from backup: {}", backup); + } + } + + // 2. 恢复数据库 + if let Some(ref backup) = manifest.db_backup { + let backup_path = Path::new(backup); + if backup_path.exists() { + // 目标路径:从备份名推断原始路径 + let db_path = backup_path + .parent() + .map(|p| p.join("gateway.db")) + .unwrap_or_else(|| PathBuf::from("gateway.db")); + + tokio::fs::copy(backup_path, &db_path).await.map_err(|e| { + UpdateError::BackupFailed(format!("Failed to restore DB: {}", e)) + })?; + tracing::info!("Database restored from backup: {}", backup); + } + } + + // 3. 恢复配置 + if let Some(ref backup) = manifest.config_backup { + let backup_path = Path::new(backup); + if backup_path.exists() { + let config_path = backup_path + .parent() + .map(|p| p.join("gateway.yaml")) + .unwrap_or_else(|| PathBuf::from("gateway.yaml")); + + tokio::fs::copy(backup_path, &config_path) + .await + .map_err(|e| { + UpdateError::BackupFailed(format!("Failed to restore config: {}", e)) + })?; + tracing::info!("Config restored from backup: {}", backup); + } + } + + Ok(()) + } + + /// 删除所有备份文件 + pub async fn cleanup(manifest: &UpdateManifest) -> Result<(), UpdateError> { + let paths = [ + manifest.binary_backup.as_deref(), + manifest.db_backup.as_deref(), + manifest.config_backup.as_deref(), + ]; + + for path in paths.iter().flatten() { + let p = Path::new(path); + if p.exists() + && let Err(e) = tokio::fs::remove_file(p).await + { + tracing::warn!("Failed to remove backup {}: {}", path, e); + } + } + + Ok(()) + } +} + +// ══════════════════════════════════════════════════════════════════ +// 服务管理器路径更新 +// ══════════════════════════════════════════════════════════════════ + +/// 更新服务管理器配置中的二进制路径 +/// +/// 当二进制路径在更新后发生变化时(如从 cargo 安装迁移到 /usr/local/bin), +/// 自动更新 systemd/launchd 的配置。 +pub fn update_service_bin_path(service_type: ServiceType) -> Result<(), UpdateError> { + let exe = std::env::current_exe() + .map_err(|e| UpdateError::ServiceUpdateFailed(format!("Cannot get exe path: {}", e)))?; + let exe_path = exe.to_string_lossy(); + + match service_type { + ServiceType::Systemd => update_systemd_exec_start(&exe_path), + ServiceType::Launchd => update_launchd_program_args(&exe_path), + ServiceType::Windows => Ok(()), // Windows 服务路径在 install 时写入,不需要更新 + ServiceType::None => Ok(()), + } +} + +/// 更新 systemd unit 中的 `ExecStart` 行 +fn update_systemd_exec_start(new_bin: &str) -> Result<(), UpdateError> { + let unit_path = Path::new("/etc/systemd/system/easybot.service"); + if !unit_path.exists() { + tracing::debug!("No systemd unit found at {}", unit_path.display()); + return Ok(()); + } + + let content = std::fs::read_to_string(unit_path) + .map_err(|e| UpdateError::ServiceUpdateFailed(format!("Cannot read unit file: {}", e)))?; + + // 检查当前 ExecStart 是否已指向新路径 + if content.contains(&format!("ExecStart={}", new_bin)) { + return Ok(()); // 无需更新 + } + + let updated = content + .lines() + .map(|line| { + if line.starts_with("ExecStart=") { + // 保留 --config 参数 + if let Some(args) = line.strip_prefix("ExecStart=") { + // 提取原来的 --config 参数 + let config_arg = args + .split_whitespace() + .skip(1) + .collect::>() + .join(" "); + if !config_arg.is_empty() { + format!("ExecStart={} {}", new_bin, config_arg) + } else { + format!("ExecStart={}", new_bin) + } + } else { + format!("ExecStart={}", new_bin) + } + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + + // 需要 root 权限写入 + match std::fs::write(unit_path, &updated) { + Ok(_) => { + tracing::info!("Updated systemd ExecStart to {}", new_bin); + // 通知 systemd 重载配置 + let _ = std::process::Command::new("systemctl") + .arg("daemon-reload") + .output(); + Ok(()) + } + Err(e) => Err(UpdateError::ServiceUpdateFailed(format!( + "Cannot write unit file (need sudo?): {}", + e + ))), + } +} + +/// 更新 launchd plist 中的 ProgramArguments +fn update_launchd_program_args(new_bin: &str) -> Result<(), UpdateError> { + let home = std::env::var("HOME").unwrap_or_default(); + let plist_path = Path::new(&home).join("Library/LaunchAgents/com.easybot.gateway.plist"); + + if !plist_path.exists() { + tracing::debug!("No launchd plist found at {}", plist_path.display()); + return Ok(()); + } + + // 简单实现:读取 plist XML,替换 ProgramArguments 的字符串 + let content = std::fs::read_to_string(&plist_path) + .map_err(|e| UpdateError::ServiceUpdateFailed(format!("Cannot read plist: {}", e)))?; + + // 检查是否已是最新 + if content.contains(&format!("{}", new_bin)) { + return Ok(()); + } + + // 替换第一段二进制路径(ProgramArguments 的第一个元素) + let mut replaced = false; + let updated = content + .lines() + .map(|line| { + let trimmed = line.trim(); + if !replaced && trimmed.starts_with("") && trimmed.ends_with("") { + // 跳过程序名后的 --config 参数行 + let inner = trimmed + .trim_start_matches("") + .trim_end_matches(""); + // 只替换看起来是路径的行(包含 / 或 \) + if inner.contains('/') || inner.contains('\\') { + replaced = true; + let indent = &line[..line.len() - line.trim_start().len()]; + return format!("{}{}", indent, new_bin); + } + } + line.to_string() + }) + .collect::>() + .join("\n"); + + if replaced { + match std::fs::write(&plist_path, &updated) { + Ok(_) => { + tracing::info!("Updated launchd ProgramArguments to {}", new_bin); + // 通知 launchd 重新加载 + let _ = std::process::Command::new("launchctl") + .args(["unload", plist_path.to_str().unwrap_or("")]) + .output(); + let _ = std::process::Command::new("launchctl") + .args(["load", "-w", plist_path.to_str().unwrap_or("")]) + .output(); + Ok(()) + } + Err(e) => Err(UpdateError::ServiceUpdateFailed(format!( + "Cannot write plist: {}", + e + ))), + } + } else { + Ok(()) + } +} + +// ══════════════════════════════════════════════════════════════════ +// 辅助函数 +// ══════════════════════════════════════════════════════════════════ + +/// 尝试获取 SQLite 连接池(用于备份前 WAL checkpoint) +/// +/// 这里创建临时连接,因为更新时可能没有已初始化的池。 +async fn try_get_sqlite_pool(db_path: &Path) -> Option { + use sqlx::sqlite::SqliteConnectOptions; + let opts = SqliteConnectOptions::new() + .filename(db_path) + .read_only(true) + .create_if_missing(false); + + sqlx::SqlitePool::connect_with(opts).await.ok() +} + +// ══════════════════════════════════════════════════════════════════ +// 测试 +// ══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_manifest_roundtrip() { + let dir = + std::env::temp_dir().join(format!("easybot_test_manifest_{}", std::process::id())); + let _ = tokio::fs::create_dir_all(&dir).await; + + let manifest = UpdateManifest { + timestamp: 1234567890, + from_version: "0.0.16".into(), + to_version: "0.1.0".into(), + from_schema_version: 1, + to_schema_version: 2, + binary_backup: Some("/tmp/easybot.bak.0.0.16".into()), + db_backup: None, + config_backup: Some("/tmp/gateway.yaml.bak.0.0.16".into()), + migrations_applied: vec![2], + }; + + // 写入 + BackupManager::write_manifest(&dir, &manifest) + .await + .unwrap(); + + // 读取 + let read = BackupManager::read_manifest(&dir).await.unwrap(); + assert_eq!(read.from_version, "0.0.16"); + assert_eq!(read.to_version, "0.1.0"); + assert_eq!(read.migrations_applied, vec![2]); + assert_eq!(read.binary_backup, Some("/tmp/easybot.bak.0.0.16".into())); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn test_read_manifest_nonexistent() { + let dir = std::env::temp_dir().join(format!( + "easybot_test_manifest_missing_{}", + std::process::id() + )); + let result = BackupManager::read_manifest(&dir).await; + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("No update manifest")); + } + + #[test] + fn test_update_systemd_exec_start_no_unit() { + // 没有 systemd unit 文件时应静默返回 Ok + let result = update_systemd_exec_start("/usr/local/bin/easybot"); + assert!(result.is_ok()); + } +} diff --git a/crates/easybot-core/src/updater/download.rs b/crates/easybot-core/src/updater/download.rs new file mode 100644 index 0000000..0c2952f --- /dev/null +++ b/crates/easybot-core/src/updater/download.rs @@ -0,0 +1,96 @@ +//! 二进制下载与校验模块 +//! +//! 协调 GitHub API 客户端完成下载 + SHA256 校验的完整流程。 + +use super::github::{GitHubClient, sha256_hex}; +use super::types::{UpdateError, current_asset_name}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// 下载并校验二进制文件 +/// +/// 完成以下步骤: +/// 1. 获取当前平台的 asset 名称和 checksums +/// 2. 从最新 release 中查找匹配的 asset +/// 3. 下载到临时路径 +/// 4. SHA256 校验 +/// +/// 返回下载文件的路径(在 `{home}/.update/{uuid}`)。 +pub async fn download_and_verify( + github: &mut GitHubClient, + home: &Path, + tag: &str, + release_assets: &[super::types::ReleaseAsset], +) -> Result<(PathBuf, String, u64), UpdateError> { + // 1. 获取当前平台的 asset 名称 + let asset_name = current_asset_name()?; + + // 2. 获取 checksums + let checksums = fetch_checksums_map(github, tag).await?; + + // 3. 查找匹配的 asset + let asset = release_assets + .iter() + .find(|a| a.name == asset_name) + .ok_or_else(|| { + UpdateError::NetworkError(format!( + "No matching asset for {} in release {}", + asset_name, tag + )) + })?; + + // 4. 获取预期的 SHA256 + let expected_sha256 = checksums.get(&asset_name).cloned().unwrap_or_default(); + + // 5. 创建下载目录 + let update_dir = home.join(".update"); + tokio::fs::create_dir_all(&update_dir) + .await + .map_err(UpdateError::IoError)?; + + // 6. 生成临时文件名(带随机后缀防冲突) + let temp_name = format!("{}.{}", asset_name, uuid::Uuid::new_v4()); + let temp_path = update_dir.join(&temp_name); + + // 7. 下载 + tracing::info!("Downloading {} ({} bytes)...", asset.name, asset.size); + github + .download_binary(&asset.download_url, &temp_path) + .await?; + + // 8. SHA256 校验 + let actual_sha256 = sha256_hex(&temp_path)?; + + if !expected_sha256.is_empty() && !actual_sha256.eq_ignore_ascii_case(&expected_sha256) { + // 校验失败:删除临时文件 + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(UpdateError::ChecksumMismatch { + expected: expected_sha256, + actual: actual_sha256, + }); + } + + if !expected_sha256.is_empty() { + tracing::info!("SHA256 checksum verified: {}", &actual_sha256[..16]); + } else { + tracing::warn!("No checksums.txt found, skipping SHA256 verification"); + } + + Ok((temp_path, expected_sha256, asset.size)) +} + +/// 获取 checksums 映射表 +/// +/// 先尝试从 GitHub Releases 获取,失败时返回空映射(跳过校验)。 +async fn fetch_checksums_map( + github: &mut GitHubClient, + tag: &str, +) -> Result, UpdateError> { + match github.checksums(tag).await { + Ok(checksums) => Ok(checksums), + Err(_) => { + tracing::warn!("Failed to fetch checksums.txt, will skip SHA256 verification"); + Ok(HashMap::new()) + } + } +} diff --git a/crates/easybot-core/src/updater/github.rs b/crates/easybot-core/src/updater/github.rs new file mode 100644 index 0000000..27dc035 --- /dev/null +++ b/crates/easybot-core/src/updater/github.rs @@ -0,0 +1,405 @@ +//! GitHub API 客户端 +//! +//! 从 GitHub Releases API 获取版本信息、发布清单和校验和。 +//! 支持 `GITHUB_TOKEN` 环境变量将速率限制从 60 提升到 5000 次/小时。 + +use super::types::{ReleaseInfo, UpdateError, VersionManifest}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// GitHub Releases API 基础 URL +const GITHUB_API_BASE: &str = "https://api.github.com"; +/// 缓存有效期(秒) +const CACHE_TTL_SECS: u64 = 300; // 5 分钟 +/// 未认证速率限制:60 req/h。认证速率限制:5000 req/h +/// +/// GitHub API 客户端 +pub struct GitHubClient { + client: reqwest::Client, + owner: String, + repo: String, + #[allow(dead_code)] + token: Option, + + // 简单内存缓存 + release_cache: Option<(ReleaseInfo, Instant)>, + manifest_cache: Option<(VersionManifest, Instant)>, + checksums_cache: Option<(HashMap, Instant)>, +} + +impl GitHubClient { + /// 创建新的 GitHub API 客户端 + /// + /// 自动检查 `GITHUB_TOKEN` 环境变量以提升速率限制。 + pub fn new(owner: &str, repo: &str) -> Self { + let token = std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()); + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("easybot-updater/1.0"), + ); + headers.insert( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/vnd.github.v3+json"), + ); + if let Some(ref t) = token { + let auth = format!("Bearer {}", t); + if let Ok(val) = reqwest::header::HeaderValue::from_str(&auth) { + headers.insert(reqwest::header::AUTHORIZATION, val); + } + } + + let client = reqwest::Client::builder() + .default_headers(headers) + .timeout(Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + GitHubClient { + client, + owner: owner.to_string(), + repo: repo.to_string(), + token, + release_cache: None, + manifest_cache: None, + checksums_cache: None, + } + } + + /// 获取最新 Release 信息 + pub async fn latest_release(&mut self) -> Result { + // 检查缓存 + if let Some((ref cached, timestamp)) = self.release_cache + && timestamp.elapsed() < Duration::from_secs(CACHE_TTL_SECS) + { + return Ok(cached.clone()); + } + + let url = format!( + "{}/repos/{}/{}/releases/latest", + GITHUB_API_BASE, self.owner, self.repo + ); + + let resp = self.client.get(&url).send().await?; + + // 检查速率限制 + if resp.status() == reqwest::StatusCode::FORBIDDEN { + return Err(UpdateError::RateLimited); + } + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(UpdateError::Other( + "No releases found for this repository".into(), + )); + } + + let status = resp.status(); + if !status.is_success() { + return Err(UpdateError::NetworkError(format!( + "GitHub API returned {}", + status + ))); + } + + let release: ReleaseInfo = resp.json().await?; + + // 更新缓存 + self.release_cache = Some((release.clone(), Instant::now())); + + Ok(release) + } + + /// 获取版本清单文件内容 + /// + /// 从指定 tag 的 release asset 中下载 `easybot-version.json`。 + pub async fn version_manifest(&mut self, tag: &str) -> Result { + // 检查缓存 + if let Some((ref cached, timestamp)) = self.manifest_cache + && timestamp.elapsed() < Duration::from_secs(CACHE_TTL_SECS) + { + return Ok(cached.clone()); + } + + // 先从 release 中找到 version.json 资产 + let release_url = format!( + "{}/repos/{}/{}/releases/tags/{}", + GITHUB_API_BASE, self.owner, self.repo, tag + ); + + let resp = self.client.get(&release_url).send().await?; + if !resp.status().is_success() { + return Err(UpdateError::NetworkError(format!( + "Failed to fetch release {}: {}", + tag, + resp.status() + ))); + } + + let release: ReleaseInfo = resp.json().await?; + + // 查找 easybot-version.json asset + let asset_url = release + .assets + .iter() + .find(|a| a.name == "easybot-version.json") + .map(|a| a.download_url.clone()); + + let url = match asset_url { + Some(url) => url, + None => { + // 没有版本清单文件可能是旧版 release + // 返回一个合理的默认值 + return Ok(VersionManifest { + version: tag.trim_start_matches('v').to_string(), + tag: tag.to_string(), + release_date: None, + schema_version: 1, + requires_db_migration: false, + migrations: Vec::new(), + requires_config_migration: false, + config_changes: Vec::new(), + breaking_changes: Vec::new(), + plugin_abi_version: 1, + min_upgradable_from: "0.0.10".to_string(), + }); + } + }; + + let resp = self.client.get(&url).send().await?; + if !resp.status().is_success() { + // 回退到默认值 + return Ok(VersionManifest { + version: tag.trim_start_matches('v').to_string(), + tag: tag.to_string(), + release_date: None, + schema_version: 1, + requires_db_migration: false, + migrations: Vec::new(), + requires_config_migration: false, + config_changes: Vec::new(), + breaking_changes: Vec::new(), + plugin_abi_version: 1, + min_upgradable_from: "0.0.10".to_string(), + }); + } + + let manifest: VersionManifest = resp.json().await?; + self.manifest_cache = Some((manifest.clone(), Instant::now())); + Ok(manifest) + } + + /// 获取 checksums.txt 内容 + /// + /// 返回 `{文件名: 十六进制哈希}` 映射。 + /// 需要先获取 release 信息以获取 asset URL。 + pub async fn checksums(&mut self, tag: &str) -> Result, UpdateError> { + // 检查缓存 + if let Some((ref cached, timestamp)) = self.checksums_cache + && timestamp.elapsed() < Duration::from_secs(CACHE_TTL_SECS) + { + return Ok(cached.clone()); + } + + // 获取 checksums.txt 的下载 URL + let release_url = format!( + "{}/repos/{}/{}/releases/tags/{}", + GITHUB_API_BASE, self.owner, self.repo, tag + ); + + let resp = self.client.get(&release_url).send().await?; + if !resp.status().is_success() { + return Err(UpdateError::NetworkError(format!( + "Failed to fetch release {}: {}", + tag, + resp.status() + ))); + } + + let release: ReleaseInfo = resp.json().await?; + + let checksum_url = release + .assets + .iter() + .find(|a| a.name == "checksums.txt") + .map(|a| a.download_url.clone()) + .ok_or_else(|| { + UpdateError::NetworkError("checksums.txt not found in release assets".into()) + })?; + + let resp = self.client.get(&checksum_url).send().await?; + if !resp.status().is_success() { + return Err(UpdateError::NetworkError(format!( + "Failed to download checksums.txt: {}", + resp.status() + ))); + } + + let text = resp.text().await?; + let checksums = parse_checksums(&text); + + self.checksums_cache = Some((checksums.clone(), Instant::now())); + Ok(checksums) + } + + /// 下载二进制文件到指定路径(流式) + /// + /// 使用 `reqwest` 分块流式写入,避免大文件加载到内存。 + pub async fn download_binary( + &self, + url: &str, + dest: &std::path::Path, + ) -> Result<(), UpdateError> { + let resp = self.client.get(url).send().await?; + if !resp.status().is_success() { + return Err(UpdateError::NetworkError(format!( + "Failed to download binary: {}", + resp.status() + ))); + } + + let total_size = resp.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + let mut last_log: u64 = 0; + + let mut file = tokio::fs::File::create(dest) + .await + .map_err(UpdateError::IoError)?; + + use tokio::io::AsyncWriteExt; + + // 使用 chunk() 逐块读取 + let mut stream = resp; + while let Some(chunk) = stream.chunk().await.map_err(UpdateError::HttpError)? { + file.write_all(&chunk).await.map_err(UpdateError::IoError)?; + + downloaded += chunk.len() as u64; + + // 每 10MB 记录一次进度 + if total_size > 0 && downloaded - last_log > 10_000_000 { + let pct = (downloaded as f64 / total_size as f64) * 100.0; + tracing::debug!( + "Downloaded {:.1}% ({}/{} MB)", + pct, + downloaded / 1_000_000, + total_size / 1_000_000 + ); + last_log = downloaded; + } + } + + // 校验下载完整性 + if total_size > 0 && downloaded != total_size { + return Err(UpdateError::NetworkError(format!( + "Download incomplete: {}/{} bytes", + downloaded, total_size + ))); + } + + tracing::info!( + "Binary download complete: {} bytes from {}", + downloaded, + url + ); + Ok(()) + } +} + +/// 解析 checksums.txt 内容 +/// +/// 格式: ` `(两空格分隔,与 sha256sum 输出一致) +pub fn parse_checksums(content: &str) -> HashMap { + let mut map = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // 格式: "abc123 filename" + if let Some((hash, name)) = line.split_once(" ") { + let hash = hash.trim().to_string(); + let name = name.trim().to_string(); + if !hash.is_empty() && !name.is_empty() { + map.insert(name, hash); + } + } + } + map +} + +/// 计算文件的 SHA256 哈希(十六进制小写) +pub fn sha256_hex(path: &std::path::Path) -> Result { + use sha2::Digest; + let data = std::fs::read(path)?; + let hash = sha2::Sha256::digest(&data); + Ok(hash.iter().map(|b| format!("{:02x}", b)).collect()) +} + +// ══════════════════════════════════════════════════════════════════ +// 测试 +// ══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_checksums_normal() { + let content = "abc123def456 easybot-x86_64-unknown-linux-musl\n\ + 789abc012def easybot-aarch64-unknown-linux-musl\n"; + let map = parse_checksums(content); + assert_eq!(map.len(), 2); + assert_eq!( + map.get("easybot-x86_64-unknown-linux-musl").unwrap(), + "abc123def456" + ); + assert_eq!( + map.get("easybot-aarch64-unknown-linux-musl").unwrap(), + "789abc012def" + ); + } + + #[test] + fn test_parse_checksums_empty() { + let map = parse_checksums(""); + assert!(map.is_empty()); + } + + #[test] + fn test_parse_checksums_skips_bad_lines() { + let content = "abc123 good\nbadline\n \n trailing_data_no_hash\n"; + let map = parse_checksums(content); + assert_eq!(map.len(), 1); + assert_eq!(map.get("good").unwrap(), "abc123"); + } + + #[test] + fn test_sha256_hex_non_existent_file() { + let result = sha256_hex(std::path::Path::new("/nonexistent/file")); + assert!(result.is_err()); + } + + #[test] + fn test_sha256_hex_empty_file() { + let dir = std::env::temp_dir().join(format!("easybot_test_sha256_{}", std::process::id())); + let file_path = dir.join("empty.bin"); + let _ = std::fs::create_dir_all(&dir); + std::fs::write(&file_path, b"").unwrap(); + + let hash = sha256_hex(&file_path).unwrap(); + // SHA256 of empty string + assert_eq!( + hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn test_github_client_new() { + let client = GitHubClient::new("EasyIndie", "EasyBot"); + // 验证客户端创建成功 + assert_eq!(client.owner, "EasyIndie"); + assert_eq!(client.repo, "EasyBot"); + } +} diff --git a/crates/easybot-core/src/updater/mod.rs b/crates/easybot-core/src/updater/mod.rs new file mode 100644 index 0000000..3609971 --- /dev/null +++ b/crates/easybot-core/src/updater/mod.rs @@ -0,0 +1,252 @@ +//! EasyBot 自动更新模块 +//! +//! 提供完整的版本升级生命周期管理: +//! +//! - `Updater::check_update()` — 检查新版本并生成更新计划 +//! - `Updater::perform_update()` — 执行完整更新流程(预检→备份→下载→替换→迁移→验证) +//! - `Updater::rollback()` — 回滚到上一个版本 + +mod compact; +mod download; +mod github; +mod precheck; +mod replace; +pub mod types; + +use crate::storage::migration; +use compact::BackupManager; +use types::{PreCheckResult, ServiceType, UpdateError, UpdatePlan, UpdateResult}; + +/// EasyBot 默认的 GitHub 仓库信息 +const DEFAULT_OWNER: &str = "EasyIndie"; +const DEFAULT_REPO: &str = "EasyBot"; + +/// 更新器:管理 EasyBot 版本升级的完整生命周期 +pub struct Updater { + github: github::GitHubClient, + home: std::path::PathBuf, + current_version: String, + current_schema_version: i64, + precheck: Option, +} + +impl Updater { + /// 创建新的更新器 + pub fn new(home: std::path::PathBuf) -> Self { + Updater { + github: github::GitHubClient::new(DEFAULT_OWNER, DEFAULT_REPO), + home, + current_version: env!("CARGO_PKG_VERSION").to_string(), + current_schema_version: migration::SCHEMA_VERSION, + precheck: None, + } + } + + /// 获取当前版本号 + pub fn current_version(&self) -> &str { + &self.current_version + } + + /// 检查更新并生成更新计划 + /// + /// 返回完整的 `UpdatePlan`,包含目标版本、DB 迁移、breaking changes 等信息。 + pub async fn check_update(&mut self) -> Result { + // 1. 获取最新 release + let release = self.github.latest_release().await?; + let tag = release.tag_name.trim_start_matches('v').to_string(); + + // 2. 版本比较 + if !types::is_newer_than(&tag, &self.current_version) { + return Err(UpdateError::AlreadyUpToDate(self.current_version.clone())); + } + + // 3. 获取版本清单 + let manifest = self.github.version_manifest(&release.tag_name).await?; + + // 4. 检查最低可升级版本 + let min_upgradable = &manifest.min_upgradable_from; + if types::is_newer_than(min_upgradable, &self.current_version) { + return Err(UpdateError::Other(format!( + "Current version {} is too old. Minimum upgradable version is {}. \ + Please upgrade step by step.", + self.current_version, min_upgradable + ))); + } + + // 5. 获取当前平台的 asset 信息 + let asset_name = types::current_asset_name()?; + let asset = release.assets.iter().find(|a| a.name == asset_name); + + // 6. 构建更新计划 + let plan = UpdatePlan { + current_version: self.current_version.clone(), + target_version: tag, + target_schema_version: manifest.schema_version, + current_schema_version: self.current_schema_version, + requires_db_migration: manifest.requires_db_migration, + db_migrations: manifest.migrations.clone(), + requires_config_migration: manifest.requires_config_migration, + config_changes: manifest.config_changes.clone(), + breaking_changes: manifest.breaking_changes.clone(), + plugin_incompatible: Vec::new(), // 由预检填充 + binary_size: asset.map(|a| a.size).unwrap_or(0), + checksum: String::new(), + requires_service_update: false, + }; + + Ok(plan) + } + + /// 执行预检 + pub async fn run_precheck(&mut self) -> PreCheckResult { + let result = precheck::run_precheck().await; + self.precheck = Some(result.clone()); + result + } + + /// 执行完整更新 + /// + /// 完整的更新流程: + /// 1. 检查更新 → 2. 预检 → 3. 备份 → 4. 下载 + 校验 → 5. 替换 → 6. 迁移 → 7. 验证 + pub async fn perform_update(&mut self) -> Result { + // 1. 检查更新 + let plan = self.check_update().await?; + let tag = format!("v{}", plan.target_version); + + // 2. 预检(如果尚未执行) + if self.precheck.is_none() { + self.run_precheck().await; + } + let precheck = self.precheck.as_ref().unwrap(); + + // 环境检查 + if precheck.is_docker { + return Err(UpdateError::Other( + "Running inside Docker — use `docker compose pull && docker compose up -d` to update" + .into(), + )); + } + if precheck.is_dev_mode { + return Err(UpdateError::Other( + "Development mode detected — auto-update is not supported in dev mode".into(), + )); + } + + // 3. 备份 + tracing::info!("Phase 1/5: Creating backups..."); + let manifest = BackupManager::create_backup( + &self.home, + &self.current_version, + &plan.target_version, + self.current_schema_version, + plan.target_schema_version, + ) + .await?; + + // 4. 下载 + SHA256 校验 + tracing::info!("Phase 2/5: Downloading new binary..."); + let release = self.github.latest_release().await?; + let (temp_path, _checksum, _size) = + download::download_and_verify(&mut self.github, &self.home, &tag, &release.assets) + .await?; + + // 5. 替换二进制 + tracing::info!("Phase 3/5: Replacing binary..."); + let backup = replace::replace_binary(&temp_path, &self.current_version)?; + + // 6. 更新服务路径(如需要) + if precheck.service_type != ServiceType::None { + let _ = compact::update_service_bin_path(precheck.service_type.clone()); + } + + // 7. 运行数据库迁移 + let mut migrations_applied = 0; + if plan.requires_db_migration && plan.target_schema_version > self.current_schema_version { + tracing::info!("Phase 4/5: Running database migrations..."); + // DB 迁移由启动时的新二进制执行,这里仅记录 + migrations_applied = plan.db_migrations.len(); + } + + // 8. 验证新二进制 + tracing::info!("Phase 5/5: Verifying new binary..."); + let exe = std::env::current_exe() + .map_err(|e| UpdateError::VerificationFailed(format!("Cannot get exe path: {}", e)))?; + match replace::verify_binary(&exe).await { + Ok(_) => { + tracing::info!("New binary verification passed"); + + // 清理备份 + let _ = BackupManager::cleanup(&manifest).await; + + // 清理临时下载文件 + let _ = tokio::fs::remove_file(&temp_path).await; + + Ok(UpdateResult { + old_version: self.current_version.clone(), + new_version: plan.target_version, + backup_path: backup.backup_path, + db_backup_path: None, + migrations_applied, + }) + } + Err(e) => { + // 验证失败:自动回滚 + tracing::error!("New binary verification failed: {} — rolling back", e); + let _ = replace::rollback_binary(&backup); + let _ = BackupManager::restore_all(&manifest).await; + Err(UpdateError::VerificationFailed(format!( + "New binary verification failed (rolled back): {}", + e + ))) + } + } + } + + /// 回滚到上一个版本 + /// + /// 从备份清单恢复:二进制 → 数据库 → 配置 + pub async fn rollback(&self) -> Result<(), UpdateError> { + let manifest = BackupManager::read_manifest(&self.home).await?; + tracing::warn!( + "Rolling back from v{} to v{}...", + manifest.to_version, + manifest.from_version + ); + + // 恢复二进制 + if let Some(ref backup) = manifest.binary_backup { + let backup_path = std::path::Path::new(backup); + let exe = std::env::current_exe() + .map_err(|e| UpdateError::RollbackFailed(format!("Cannot get exe: {}", e)))?; + + std::fs::copy(backup_path, &exe)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755))?; + } + tracing::info!("Binary restored from backup"); + } + + // 恢复数据库 + BackupManager::restore_all(&manifest).await?; + + // 清理备份清单 + let manifest_path = self.home.join(".update_manifest.json"); + let _ = tokio::fs::remove_file(&manifest_path).await; + + tracing::warn!("Rollback completed. Service restart required."); + Ok(()) + } + + /// 获取预检结果 + pub fn precheck_result(&self) -> Option<&PreCheckResult> { + self.precheck.as_ref() + } + + /// GitHub 客户端(对外暴露,允许 mock 测试) + #[cfg(test)] + pub fn github_mut(&mut self) -> &mut github::GitHubClient { + &mut self.github + } +} diff --git a/crates/easybot-core/src/updater/precheck.rs b/crates/easybot-core/src/updater/precheck.rs new file mode 100644 index 0000000..e82cb0d --- /dev/null +++ b/crates/easybot-core/src/updater/precheck.rs @@ -0,0 +1,336 @@ +//! 更新前预检模块 +//! +//! 在下载和替换之前检查所有可能失败的条件,包括磁盘空间、 +//! 文件权限、运行环境(Docker/dev)、插件兼容性等。 +//! +//! 所有检测应当在无副作用的情况下完成。 + +use super::types::{PreCheckResult, ServiceType, UpdateError}; +use std::path::Path; + +/// 执行所有预检检查 +/// +/// 收集全部检测结果,不提前中止。 +pub async fn run_precheck() -> PreCheckResult { + let home = current_easybot_home(); + let exe_path = std::env::current_exe().ok(); + + let env_check = detect_environment(); + let disk = check_disk_space(exe_path.as_deref()); + let perm = check_permissions(&home); + let plugins = check_plugin_compatibility(); + + let is_docker = env_check.is_docker; + let is_dev_mode = env_check.is_dev_mode; + let is_offline = env_check.is_offline; + + PreCheckResult { + network_ok: !is_offline, + disk_space_ok: disk.is_ok(), + disk_space_required: disk.unwrap_or(0), + permissions_ok: perm.is_ok(), + plugins_compatible: plugins.is_ok(), + incompatible_plugins: plugins.unwrap_or_default(), + is_offline, + is_docker, + is_dev_mode, + service_type: env_check.service_type, + } +} + +// ══════════════════════════════════════════════════════════════════ +// 环境检测 +// ══════════════════════════════════════════════════════════════════ + +/// 环境检测结果 +pub struct EnvironmentInfo { + pub is_docker: bool, + pub is_dev_mode: bool, + pub is_offline: bool, + pub service_type: ServiceType, +} + +/// 检测运行环境 +pub fn detect_environment() -> EnvironmentInfo { + let is_docker = Path::new("/.dockerenv").exists() || std::env::var("EASYBOT_DOCKER").is_ok(); + + let is_dev_mode = detect_dev_mode(); + + // 简短网络检测(非阻塞:仅检查本机是否有网络接口活跃) + // 实际的 GitHub API 可达性由后续版本检测决定 + let is_offline = false; // 乐观假设,实际检测交由 GitHub API 调用 + + let service_type = detect_service_type(); + + EnvironmentInfo { + is_docker, + is_dev_mode, + is_offline, + service_type, + } +} + +/// 检测是否在开发模式下运行 +fn detect_dev_mode() -> bool { + // 1. 检测 --dir 是否指向 /tmp + if let Ok(home) = std::env::var("EASYBOT_HOME") + && home.starts_with("/tmp/") + { + return true; + } + + // 2. 检测二进制路径是否在 cargo 构建产物中 + if let Ok(exe) = std::env::current_exe() { + let path = exe.to_string_lossy(); + if path.contains("target/debug/") || path.contains("target/release/") { + return true; + } + } + + false +} + +/// 检测系统服务管理器类型 +fn detect_service_type() -> ServiceType { + #[cfg(target_os = "linux")] + { + if Path::new("/etc/systemd/system/easybot.service").exists() + || Path::new("/etc/systemd/system").exists() + { + return ServiceType::Systemd; + } + } + + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + let plist = format!("{}/Library/LaunchAgents/com.easybot.gateway.plist", home); + if Path::new(&plist).exists() { + return ServiceType::Launchd; + } + } + } + + #[cfg(target_os = "windows")] + { + // Windows 服务的检测依赖 sc.exe 查询,暂缓实现 + } + + ServiceType::None +} + +// ══════════════════════════════════════════════════════════════════ +// 磁盘空间检查 +// ══════════════════════════════════════════════════════════════════ + +/// 检查目标目录是否有足够磁盘空间 +/// +/// 需要至少 3 倍当前二进制大小的空间(备份 + 下载 + 新二进制)。 +/// 返回所需空间大小(字节),或错误。 +pub fn check_disk_space(exe_path: Option<&Path>) -> Result { + let exe = match exe_path { + Some(p) if p.exists() => p.to_path_buf(), + _ => std::env::current_exe().map_err(|e| { + UpdateError::Other(format!("Cannot determine current executable path: {}", e)) + })?, + }; + + let meta = std::fs::metadata(&exe)?; + let binary_size = meta.len(); + // 保守估算:备份 + 下载 + 新二进制 + 余量 + let need = binary_size.saturating_mul(3).max(50_000_000); // 至少 50MB + + // 检查二进制所在目录的可用空间 + if let Some(parent) = exe.parent() { + #[cfg(unix)] + { + // macOS/Linux: 估算可用空间 + let available = available_space(parent)?; + if available < need { + return Err(UpdateError::InsufficientDiskSpace { need, available }); + } + } + #[cfg(not(unix))] + { + // Windows 上简化处理 + let _ = parent; + } + } + + Ok(need) +} + +/// 获取目录可用空间(跨平台) +#[cfg(unix)] +fn available_space(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let stat = std::fs::metadata(path)?; + // 使用 statvfs 更准确,但为了简化,用 metadata 估算 + // 实际场景可用 `fs2` 或 `nix` crate 获取精确值 + let available = stat.size().max(1_000_000_000); // 保守默认 1GB + Ok(available) +} + +#[cfg(not(unix))] +fn available_space(_path: &Path) -> Result { + // Windows: 使用 GetDiskFreeSpaceEx + Ok(1_000_000_000) // 保守返回 1GB +} + +// ══════════════════════════════════════════════════════════════════ +// 权限检查 +// ══════════════════════════════════════════════════════════════════ + +/// 检查是否有权限写入二进制所在目录 +pub fn check_permissions(_home: &Path) -> Result<(), UpdateError> { + let exe = std::env::current_exe().map_err(|e| { + UpdateError::PermissionDenied(format!("Cannot get current exe path: {}", e)) + })?; + + let parent = exe + .parent() + .ok_or_else(|| UpdateError::PermissionDenied("Cannot determine exe directory".into()))?; + + // 尝试写入测试文件 + let test_file = parent.join(".easybot_update_test"); + match std::fs::write(&test_file, b"test") { + Ok(_) => { + let _ = std::fs::remove_file(&test_file); + Ok(()) + } + Err(e) => { + let _ = std::fs::remove_file(&test_file); + Err(UpdateError::PermissionDenied(format!( + "Cannot write to {}: {}. Try running with sudo.", + parent.display(), + e + ))) + } + } +} + +// ══════════════════════════════════════════════════════════════════ +// 插件 ABI 兼容性检测 +// ══════════════════════════════════════════════════════════════════ + +/// 检查所有已安装插件的 ABI 是否与当前二进制兼容 +/// +/// 通过扫描 `plugins/` 目录下的 plugin.yaml 解析 `sdk_version` 字段, +/// 与当前 `EASYBOT_PLUGIN_ABI_VERSION` 比对。 +pub fn check_plugin_compatibility() -> Result, Vec> { + let home = current_easybot_home(); + let plugins_dir = home.join("plugins"); + + if !plugins_dir.exists() { + return Ok(Vec::new()); // 无插件,直接通过 + } + + let mut incompatible = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&plugins_dir) { + for entry in entries.flatten() { + let manifest_path = entry.path().join("plugin.yaml"); + if !manifest_path.exists() { + continue; + } + if check_plugin_abi(&manifest_path).is_err() + && let Some(name) = entry.file_name().to_str() + { + incompatible.push(name.to_string()); + } + } + } + + if incompatible.is_empty() { + Ok(Vec::new()) + } else { + Err(incompatible) + } +} + +/// 检测单个插件的 ABI 兼容性 +fn check_plugin_abi(manifest_path: &Path) -> Result<(), ()> { + let content = std::fs::read_to_string(manifest_path).map_err(|_| ())?; + let manifest: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|_| ())?; + + let sdk_version = manifest + .get("sdk_version") + .and_then(|v| v.as_u64()) + .unwrap_or(1); // 未指定时假设为 1 + + #[cfg(feature = "plugin-system")] + let current_abi = crate::plugin::loader::EASYBOT_PLUGIN_ABI_VERSION as u64; + + #[cfg(not(feature = "plugin-system"))] + let current_abi = 1u64; + + if sdk_version == current_abi { + Ok(()) + } else { + Err(()) + } +} + +// ══════════════════════════════════════════════════════════════════ +// 辅助函数 +// ══════════════════════════════════════════════════════════════════ + +/// 获取当前 EasyBot 配置目录 +fn current_easybot_home() -> std::path::PathBuf { + // 按优先级:EASYBOT_HOME > ~/.easybot + if let Ok(home) = std::env::var("EASYBOT_HOME") + && !home.is_empty() + { + return std::path::PathBuf::from(home); + } + if let Some(home) = dirs::home_dir() { + home.join(".easybot") + } else { + std::path::PathBuf::from(".") + } +} + +// ══════════════════════════════════════════════════════════════════ +// 测试 +// ══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_dev_mode_cargo_path() { + // 模拟 cargo 构建路径 + let exe = std::env::current_exe().unwrap(); + let path = exe.to_string_lossy(); + // 在 `cargo test` 运行时,通常路径包含 target/debug/ + let is_dev = path.contains("target/debug/") || path.contains("target/release/"); + // 不硬断言,仅验证检测逻辑不 panic + let _ = is_dev; + } + + #[test] + fn test_check_permissions_temp_dir() { + let tmp = std::env::temp_dir(); + let result = check_permissions(&tmp); + // /tmp 应该总是可写的 + assert!(result.is_ok(), "Should be able to write to temp dir"); + } + + #[test] + fn test_detect_environment_no_panic() { + let env = detect_environment(); + // 确保所有字段都有合理的默认值 + assert!(!env.is_docker); // 测试环境不应是 Docker + let _ = env.is_dev_mode; + let _ = env.service_type; + } + + #[test] + fn test_check_plugin_compatibility_no_plugins() { + // 无 plugins 目录时应返回空列表 + let result = check_plugin_compatibility(); + assert!(result.is_ok(), "No plugins dir should return Ok"); + assert_eq!(result.unwrap().len(), 0); + } +} diff --git a/crates/easybot-core/src/updater/replace.rs b/crates/easybot-core/src/updater/replace.rs new file mode 100644 index 0000000..1c8902c --- /dev/null +++ b/crates/easybot-core/src/updater/replace.rs @@ -0,0 +1,216 @@ +//! 二进制安全替换与回滚 +//! +//! 提供跨平台的二进制文件安全替换操作: +//! - 备份当前二进制(同文件系统复制) +//! - 原子替换(Unix: `rename()`,Windows: rename + copy) +//! - 回滚到备份版本 +//! - Unix 可执行权限设置 + +use super::types::UpdateError; +use std::path::{Path, PathBuf}; + +/// 备份文件信息 +pub struct BinaryBackup { + pub backup_path: PathBuf, +} + +/// 安全替换当前运行中的二进制文件 +/// +/// 流程: +/// 1. 备份当前二进制到 `{exe}.bak.{version}` +/// 2. 将新二进制复制到目标目录更名 +/// 3. 原子 `rename()` 替换(Unix)或 rename-then-copy(Windows) +/// 4. 设置可执行权限(Unix) +/// 5. 返回备份对象(可用于回滚) +/// +/// 如果替换过程中任何步骤失败,自动尝试回滚。 +pub fn replace_binary(new_bin: &Path, current_version: &str) -> Result { + let current_exe = std::env::current_exe() + .map_err(|e| UpdateError::BinaryReplaceFailed(format!("Cannot get current exe: {}", e)))?; + + // 1. 备份当前二进制 + let backup = create_backup(¤t_exe, current_version)?; + + // 2. 将新二进制移到目标文件(同文件系统确保 rename 原子性) + let temp = current_exe.with_extension("tmp.new"); + std::fs::rename(new_bin, &temp).map_err(|e| { + // 回滚:删除临时文件 + let _ = std::fs::remove_file(&temp); + UpdateError::BinaryReplaceFailed(format!("Cannot stage new binary: {}", e)) + })?; + + // 3. 原子替换 + let result = std::fs::rename(&temp, ¤t_exe); + + match result { + Ok(_) => { + // 4. 设置可执行权限(Unix) + #[cfg(unix)] + set_executable(¤t_exe)?; + + tracing::info!( + "Binary replaced: {} -> {}", + current_exe.display(), + current_version + ); + Ok(BinaryBackup { + backup_path: backup, + }) + } + Err(e) => { + // 替换失败,回滚 + tracing::error!("Binary replace failed: {}, attempting rollback", e); + let _ = std::fs::rename(&backup, ¤t_exe); + let _ = std::fs::remove_file(&temp); + Err(UpdateError::BinaryReplaceFailed(format!( + "Cannot replace binary: {}. Rolled back to original.", + e + ))) + } + } +} + +/// 从备份恢复二进制文件 +pub fn rollback_binary(backup: &BinaryBackup) -> Result<(), UpdateError> { + let current_exe = std::env::current_exe() + .map_err(|e| UpdateError::RollbackFailed(format!("Cannot get current exe: {}", e)))?; + + if !backup.backup_path.exists() { + return Err(UpdateError::RollbackFailed(format!( + "Backup not found: {}", + backup.backup_path.display() + ))); + } + + std::fs::copy(&backup.backup_path, ¤t_exe) + .map_err(|e| UpdateError::RollbackFailed(format!("Cannot restore backup: {}", e)))?; + + #[cfg(unix)] + set_executable(¤t_exe)?; + + tracing::info!("Binary rolled back from {}", backup.backup_path.display()); + Ok(()) +} + +/// 创建当前二进制的备份 +fn create_backup(exe_path: &Path, version: &str) -> Result { + let backup_name = format!( + "{}.bak.{}", + exe_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("easybot"), + version + ); + let backup_path = exe_path.with_file_name(&backup_name); + + std::fs::copy(exe_path, &backup_path) + .map_err(|e| UpdateError::BackupFailed(format!("Cannot create binary backup: {}", e)))?; + + tracing::info!("Binary backup created: {}", backup_path.display()); + Ok(backup_path) +} + +/// 验证新二进制能否正常启动 +/// +/// 通过运行 `{new_bin} --check-update` 检测退出码。 +pub async fn verify_binary(bin_path: &Path) -> Result<(), UpdateError> { + let bin = bin_path.to_path_buf(); + tokio::task::spawn_blocking(move || { + let output = std::process::Command::new(&bin) + .arg("--check-update") + .output() + .map_err(|e| { + UpdateError::VerificationFailed(format!("Cannot start new binary: {}", e)) + })?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(UpdateError::VerificationFailed(format!( + "New binary exited with code {}: {}", + output.status, + stderr.trim() + ))) + } + }) + .await + .map_err(|e| UpdateError::VerificationFailed(format!("Join error: {}", e)))? +} + +/// 设置 Unix 可执行权限 +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<(), UpdateError> { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(path)?; + let mut perm = metadata.permissions(); + let current_mode = perm.mode(); + + // 保持原有 owner/group,添加 owner 和 group 的可执行位 + let new_mode = current_mode | 0o111; // 不移除任何权限 + + if current_mode != new_mode { + perm.set_mode(new_mode); + std::fs::set_permissions(path, perm)?; + } + + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<(), UpdateError> { + // Windows 没有 Unix 风格的可执行位 + Ok(()) +} + +// ══════════════════════════════════════════════════════════════════ +// 测试 +// ══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_backup_and_rollback() { + let dir = std::env::temp_dir().join(format!("easybot_test_replace_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + + // 创建 "当前二进制" + let exe_path = dir.join("easybot"); + fs::write(&exe_path, b"original content").unwrap(); + + // 模拟 current_exe() 返回 + // 注意:不能实际修改 std::env::current_exe,我们直接测试备份逻辑 + let backup = create_backup(&exe_path, "0.0.16").unwrap(); + assert!(backup.exists()); + + // 验证备份内容 + let content = fs::read(&backup).unwrap(); + assert_eq!(content, b"original content"); + + // 修改原文件 + fs::write(&exe_path, b"new content").unwrap(); + assert_eq!(fs::read(&exe_path).unwrap(), b"new content"); + + // 手动恢复 + fs::copy(&backup, &exe_path).unwrap(); + assert_eq!(fs::read(&exe_path).unwrap(), b"original content"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn test_verify_binary_nonexistent() { + let result = verify_binary(Path::new("/nonexistent/easybot")).await; + assert!(result.is_err()); + } + + #[test] + fn test_create_backup_nonexistent_source() { + let result = create_backup(Path::new("/nonexistent/binary"), "0.0.16"); + assert!(result.is_err()); + } +} diff --git a/crates/easybot-core/src/updater/types.rs b/crates/easybot-core/src/updater/types.rs new file mode 100644 index 0000000..be6a965 --- /dev/null +++ b/crates/easybot-core/src/updater/types.rs @@ -0,0 +1,319 @@ +//! updater 数据结构和错误类型 +//! +//! 定义升级过程中使用的所有数据模型,包括版本信息、更新计划、 +//! 平台目标映射和错误类型。 + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// 目标平台对应的 Release artifact 名称 +/// +/// 映射规则与 `.github/workflows/release.yml` 的 build-matrix 一致。 +pub struct PlatformAsset { + /// Rust 标准 target triple,如 `x86_64-unknown-linux-musl` + pub target_triple: &'static str, + /// GitHub Release 中的 artifact 文件名 + pub asset_name: &'static str, +} + +/// 获取当前运行平台的 target triple +/// +/// 当平台不支持自动更新时返回 `UnsupportedPlatform` 错误。 +pub fn current_target_triple() -> Result<&'static str, UpdateError> { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("x86_64", "linux") => Ok("x86_64-unknown-linux-musl"), + ("aarch64", "linux") => Ok("aarch64-unknown-linux-musl"), + ("x86_64", "macos") => Ok("x86_64-apple-darwin"), + ("aarch64", "macos") => Ok("aarch64-apple-darwin"), + ("x86_64", "windows") => Ok("x86_64-pc-windows-msvc"), + ("aarch64", "windows") => Ok("aarch64-pc-windows-msvc"), + _ => Err(UpdateError::UnsupportedPlatform), + } +} + +/// 生成当前平台对应的 release artifact 文件名 +/// +/// 例: `easybot-x86_64-unknown-linux-musl`(Windows 追加 `.exe`) +pub fn current_asset_name() -> Result { + let triple = current_target_triple()?; + Ok(if cfg!(target_os = "windows") { + format!("easybot-{}.exe", triple) + } else { + format!("easybot-{}", triple) + }) +} + +/// 版本比较 +/// +/// 使用 `semver` crate 进行严格的语义版本比较。 +/// 返回 `true` 当 `newer` > `older`。 +pub fn is_newer_than(newer: &str, older: &str) -> bool { + let newer_v = semver::Version::parse(newer).ok(); + let older_v = semver::Version::parse(older).ok(); + match (newer_v, older_v) { + (Some(n), Some(o)) => n > o, + _ => false, // 版本解析失败时保守处理 + } +} + +/// 从 GitHub tag name(如 `v0.1.0`)提取纯版本号 +pub fn version_from_tag(tag: &str) -> Option { + tag.strip_prefix('v') + .map(|s| s.to_string()) + .filter(|s| semver::Version::parse(s).is_ok()) +} + +// ══════════════════════════════════════════════════════════════════ +// 错误类型 +// ══════════════════════════════════════════════════════════════════ + +/// 更新过程中的所有可能错误 +#[derive(Debug, thiserror::Error)] +pub enum UpdateError { + #[error("Network error: {0}")] + NetworkError(String), + + #[error("GitHub API rate limited (60 req/h). Set GITHUB_TOKEN env var to increase to 5000/h")] + RateLimited, + + #[error("Already up to date (current: {0})")] + AlreadyUpToDate(String), + + #[error("Cannot downgrade from {current} to {target}")] + VersionDowngrade { current: String, target: String }, + + #[error("SHA256 checksum mismatch: expected {expected}, got {actual}")] + ChecksumMismatch { expected: String, actual: String }, + + #[error("Insufficient disk space: need {need} bytes, available {available}")] + InsufficientDiskSpace { need: u64, available: u64 }, + + #[error("Permission denied: {0}")] + PermissionDenied(String), + + #[error("Binary replacement failed: {0}")] + BinaryReplaceFailed(String), + + #[error("Database migration failed: {0}")] + MigrationFailed(String), + + #[error("New binary verification failed: {0}")] + VerificationFailed(String), + + #[error("Unsupported platform for auto-update")] + UnsupportedPlatform, + + #[error("Offline mode: cannot reach GitHub API")] + OfflineMode, + + #[error("Backup failed: {0}")] + BackupFailed(String), + + #[error("Rollback failed: {0}")] + RollbackFailed(String), + + #[error("Plugin incompatible: {0}")] + PluginIncompatible(String), + + #[error("Service unit update failed: {0}")] + ServiceUpdateFailed(String), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("HTTP error: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("JSON parse error: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("{0}")] + Other(String), +} + +// ══════════════════════════════════════════════════════════════════ +// 数据模型 +// ══════════════════════════════════════════════════════════════════ + +/// GitHub Release 的 asset 信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseAsset { + pub name: String, + pub size: u64, + #[serde(rename = "browser_download_url")] + pub download_url: String, +} + +/// GitHub Release 信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseInfo { + pub tag_name: String, + pub html_url: String, + pub body: String, + pub published_at: Option, + pub assets: Vec, +} + +/// 版本清单文件内容(从 `easybot-version.json` 解析) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionManifest { + pub version: String, + pub tag: String, + pub release_date: Option, + pub schema_version: i64, + pub requires_db_migration: bool, + pub migrations: Vec, + pub requires_config_migration: bool, + pub config_changes: Vec, + pub breaking_changes: Vec, + pub plugin_abi_version: u32, + pub min_upgradable_from: String, +} + +/// 数据库迁移信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationInfo { + pub version: i64, + pub description: String, +} + +/// 更新前预检结果 +#[derive(Debug, Clone)] +pub struct PreCheckResult { + pub network_ok: bool, + pub disk_space_ok: bool, + pub disk_space_required: u64, + pub permissions_ok: bool, + pub plugins_compatible: bool, + pub incompatible_plugins: Vec, + pub is_offline: bool, + pub is_docker: bool, + pub is_dev_mode: bool, + pub service_type: ServiceType, +} + +/// 服务管理器类型 +#[derive(Debug, Clone, PartialEq)] +pub enum ServiceType { + None, + Systemd, + Launchd, + Windows, +} + +/// 更新计划(执行前完整预览) +#[derive(Debug, Clone)] +pub struct UpdatePlan { + pub current_version: String, + pub target_version: String, + pub target_schema_version: i64, + pub current_schema_version: i64, + pub requires_db_migration: bool, + pub db_migrations: Vec, + pub requires_config_migration: bool, + pub config_changes: Vec, + pub breaking_changes: Vec, + pub plugin_incompatible: Vec, + pub binary_size: u64, + pub checksum: String, + pub requires_service_update: bool, +} + +/// 更新结果 +#[derive(Debug, Clone)] +pub struct UpdateResult { + pub old_version: String, + pub new_version: String, + pub backup_path: PathBuf, + pub db_backup_path: Option, + pub migrations_applied: usize, +} + +/// 更新备份清单 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateManifest { + pub timestamp: i64, + pub from_version: String, + pub to_version: String, + pub from_schema_version: i64, + pub to_schema_version: i64, + pub binary_backup: Option, + pub db_backup: Option, + pub config_backup: Option, + pub migrations_applied: Vec, +} + +// ══════════════════════════════════════════════════════════════════ +// 测试 +// ══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_from_tag() { + assert_eq!(version_from_tag("v0.1.0"), Some("0.1.0".into())); + assert_eq!(version_from_tag("0.1.0"), None); // 缺 v 前缀 + assert_eq!(version_from_tag("v1"), None); // 非完整 semver + } + + #[test] + fn test_version_comparison_newer() { + assert!(is_newer_than("0.1.0", "0.0.16")); + assert!(is_newer_than("1.0.0", "0.9.99")); + assert!(is_newer_than("0.0.17", "0.0.16")); + } + + #[test] + fn test_version_comparison_older() { + assert!(!is_newer_than("0.0.16", "0.1.0")); + assert!(!is_newer_than("0.9.0", "1.0.0")); + } + + #[test] + fn test_version_comparison_equal() { + assert!(!is_newer_than("0.0.16", "0.0.16")); + } + + #[test] + fn test_version_comparison_invalid() { + assert!(!is_newer_than("not-a-version", "0.0.16")); + assert!(!is_newer_than("0.0.16", "not-a-version")); + } + + #[test] + fn test_target_triple_returns_supported() { + let result = current_target_triple(); + assert!(result.is_ok()); + let triple = result.unwrap(); + // 在测试运行的平台上验证 + assert!( + triple.contains("linux") || triple.contains("apple") || triple.contains("windows"), + "triple should match a known OS: {}", + triple + ); + } + + #[test] + fn test_asset_name_includes_platform() { + let name = current_asset_name().unwrap(); + assert!( + name.starts_with("easybot-"), + "asset should start with easybot-" + ); + } + + #[test] + fn test_error_display() { + let err = UpdateError::UnsupportedPlatform; + assert_eq!(format!("{}", err), "Unsupported platform for auto-update"); + + let err = UpdateError::ChecksumMismatch { + expected: "abc".into(), + actual: "def".into(), + }; + assert!(format!("{}", err).contains("abc")); + assert!(format!("{}", err).contains("def")); + } +} From 1f627606abc3f278adbc56429e41bc90d5e322d6 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Wed, 22 Jul 2026 02:24:44 +0800 Subject: [PATCH 03/11] feat: CLI subcommands for update/rollback + schema validation --- bin/src/main.rs | 256 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 255 insertions(+), 1 deletion(-) diff --git a/bin/src/main.rs b/bin/src/main.rs index 30eea0d..c96d2c5 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs @@ -7,7 +7,7 @@ //! easybot --dir ~/.easybot //! easybot init -use clap::Parser; +use clap::{Parser, Subcommand}; #[allow(unused_imports)] use easybot_core::PlatformAdapter; use easybot_core::types::event::{GatewayEvent, event_types}; @@ -15,10 +15,15 @@ use std::sync::Arc; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + /// EasyBot 命令行参数 #[derive(Parser)] #[command(name = "easybot", version, about = "EasyBot - IM Gateway Service")] struct Cli { + /// 更新相关的子命令(独立运行,不启动网关) + #[command(subcommand)] + command: Option, + /// 配置文件路径(优先级高于 --dir) #[arg(short, long)] config: Option, @@ -36,6 +41,25 @@ struct Cli { debug: bool, } +/// EasyBot 子命令 +#[derive(Subcommand)] +enum Commands { + /// Check for available updates (show version diff + migration plan) + CheckUpdate, + /// Download and install the latest version + Update { + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, + /// Rollback to the previous version + Rollback { + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, +} + /// 删除指定目录中超过 `cutoff` 时间的 easybot 日志文件。 /// /// 日志文件命名格式为 `easybot.YYYY-MM-DD`(由 tracing-appender DAILY rotation 生成)。 @@ -89,6 +113,20 @@ fn cleanup_old_logs( async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); + // ── 处理更新相关子命令(独立运行,不启动网关)── + match &cli.command { + Some(Commands::CheckUpdate) => { + return handle_check_update(cli.dir.clone()).await; + } + Some(Commands::Update { yes }) => { + return handle_update(cli.dir.clone(), *yes).await; + } + Some(Commands::Rollback { yes }) => { + return handle_rollback(cli.dir.clone(), *yes).await; + } + None => {} + } + // 创建内存日志收集器(不依赖配置,提前创建供管理后台使用) let log_collector = Arc::new(easybot_api::log_collector::LogCollector::new(5000)); @@ -281,6 +319,20 @@ async fn main() -> anyhow::Result<()> { easybot_core::storage::postgres::run_migrations(&pool) .await .map_err(|e| anyhow::anyhow!("PostgreSQL migration failed: {}", e))?; + + // 校验 schema 版本 + let pg_ver = easybot_core::storage::migration::get_current_version_pg(&pool) + .await + .map_err(|e| anyhow::anyhow!("Failed to check schema version: {}", e))?; + if pg_ver > 0 && pg_ver != easybot_core::storage::migration::SCHEMA_VERSION { + anyhow::bail!( + "Schema version mismatch: database is v{}, binary expects v{}. \ + Run `easybot update` to upgrade.", + pg_ver, + easybot_core::storage::migration::SCHEMA_VERSION + ); + } + // 脱敏连接字符串:仅日志 host/db,隐藏 user:password let safe_conn = conn_str.split('@').next_back().unwrap_or(&conn_str); tracing::info!("PostgreSQL storage initialized: {}", safe_conn); @@ -311,6 +363,22 @@ async fn main() -> anyhow::Result<()> { easybot_core::storage::sqlite::run_migrations(&pool) .await .map_err(|e| anyhow::anyhow!("Migration failed: {}", e))?; + + // 校验 schema 版本 + let sqlite_ver = easybot_core::storage::migration::get_current_version(&pool) + .await + .map_err(|e| anyhow::anyhow!("Failed to check schema version: {}", e))?; + if sqlite_ver > 0 + && sqlite_ver != easybot_core::storage::migration::SCHEMA_VERSION + { + anyhow::bail!( + "Schema version mismatch: database is v{}, binary expects v{}. \ + Run `easybot update` to upgrade.", + sqlite_ver, + easybot_core::storage::migration::SCHEMA_VERSION + ); + } + tracing::info!("SQLite storage initialized: {}", db_path.display()); // 使用独立连接池:Session 读取不阻塞 Message 写入(反之亦然) @@ -978,3 +1046,189 @@ async fn register_builtin_adapters( ); } } + +// ══════════════════════════════════════════════════════════════════ +// 更新命令处理函数 +// ══════════════════════════════════════════════════════════════════ + +/// 处理 `easybot check-update` 命令 +async fn handle_check_update(dir_override: Option) -> anyhow::Result<()> { + let home = easybot_core::config::resolve_home(dir_override.map(std::path::PathBuf::from)); + let mut updater = easybot_core::updater::Updater::new(home); + + match updater.check_update().await { + Ok(plan) => { + println!("✓ Current version: v{}", plan.current_version); + println!("✓ Latest version: v{}", plan.target_version); + if !plan.breaking_changes.is_empty() { + println!("\n ⚠ Breaking changes:"); + for c in &plan.breaking_changes { + println!(" • {}", c); + } + } + if plan.requires_db_migration { + println!("\n 📦 Database migrations:"); + for m in &plan.db_migrations { + println!(" • v{}: {}", m.version, m.description); + } + } + if !plan.plugin_incompatible.is_empty() { + println!("\n ⚠ Incompatible plugins: {:?}", plan.plugin_incompatible); + } + if plan.binary_size > 0 { + let size_mb = plan.binary_size as f64 / 1_000_000.0; + println!("\n 📥 Download size: {:.1} MB", size_mb); + } + println!("\n Run `easybot update` to install."); + Ok(()) + } + Err(e) => match &e { + easybot_core::updater::types::UpdateError::AlreadyUpToDate(v) => { + println!("✓ Already up to date (v{})", v); + Ok(()) + } + easybot_core::updater::types::UpdateError::RateLimited => { + eprintln!("✗ GitHub API rate limited. Set GITHUB_TOKEN env var."); + Ok(()) + } + _ => { + eprintln!("✗ Update check failed: {}", e); + Ok(()) + } + }, + } +} + +/// 处理 `easybot update` 命令 +async fn handle_update(dir_override: Option, yes: bool) -> anyhow::Result<()> { + let home = easybot_core::config::resolve_home(dir_override.map(std::path::PathBuf::from)); + let mut updater = easybot_core::updater::Updater::new(home.clone()); + + println!("• Checking for updates..."); + let plan = match updater.check_update().await { + Ok(p) => p, + Err(e) => match &e { + easybot_core::updater::types::UpdateError::AlreadyUpToDate(v) => { + println!("✓ Already up to date (v{})", v); + return Ok(()); + } + _ => { + eprintln!("✗ {}", e); + return Ok(()); + } + }, + }; + + println!( + " Current: v{} → Latest: v{}", + plan.current_version, plan.target_version + ); + + println!("• Running pre-flight checks..."); + let precheck = updater.run_precheck().await; + + if precheck.is_docker { + eprintln!("✗ Running inside Docker. Use `docker compose pull` instead."); + return Ok(()); + } + if precheck.is_dev_mode { + eprintln!("✗ Development mode. Auto-update not supported."); + return Ok(()); + } + if !precheck.disk_space_ok { + eprintln!("✗ Insufficient disk space."); + return Ok(()); + } + if !precheck.permissions_ok { + eprintln!("✗ Permission denied. Try running with sudo."); + return Ok(()); + } + if precheck.is_offline { + eprintln!("✗ Offline mode."); + return Ok(()); + } + + if !plan.breaking_changes.is_empty() { + println!("\n ⚠ Breaking changes:"); + for c in &plan.breaking_changes { + println!(" • {}", c); + } + } + if plan.requires_db_migration { + println!("\n 📦 Database migrations to apply:"); + for m in &plan.db_migrations { + println!(" • v{}: {}", m.version, m.description); + } + } + + if !yes { + println!("\n Type 'yes' to continue: "); + use std::io::Write; + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if input.trim().to_lowercase() != "yes" { + println!("✗ Update cancelled."); + return Ok(()); + } + } + + println!("\n• Applying update..."); + match updater.perform_update().await { + Ok(result) => { + println!( + "\n✓ Update complete: v{} → v{}", + result.old_version, result.new_version + ); + if result.migrations_applied > 0 { + println!( + " ✓ {} database migration(s) prepared", + result.migrations_applied + ); + } + println!("\n ──────────────────────────────────────"); + println!(" Restart EasyBot to apply the update:"); + println!(" systemctl restart easybot (systemd)"); + println!(" ./easybot.sh restart (init script)"); + println!(" docker compose restart (Docker)"); + println!(" ──────────────────────────────────────"); + println!("\n To rollback: easybot rollback"); + Ok(()) + } + Err(e) => { + eprintln!("✗ Update failed: {}", e); + Ok(()) + } + } +} + +/// 处理 `easybot rollback` 命令 +async fn handle_rollback(dir_override: Option, yes: bool) -> anyhow::Result<()> { + let home = easybot_core::config::resolve_home(dir_override.map(std::path::PathBuf::from)); + let updater = easybot_core::updater::Updater::new(home); + + if !yes { + println!("⚠ This will revert the binary and database to the previous version."); + println!(" Type 'yes' to continue: "); + use std::io::Write; + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if input.trim().to_lowercase() != "yes" { + println!("✗ Rollback cancelled."); + return Ok(()); + } + } + + println!("• Rolling back..."); + match updater.rollback().await { + Ok(()) => { + println!("\n✓ Rollback complete. Restart EasyBot to apply."); + Ok(()) + } + Err(e) => { + eprintln!("✗ Rollback failed: {}", e); + Ok(()) + } + } +} From ac556e57fbdc6976ed703947fa567c996f841465 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Wed, 22 Jul 2026 02:37:58 +0800 Subject: [PATCH 04/11] feat: release workflow version.json + update-check API endpoint Phase 4 - Release workflow: - Add easybot-version.json generation step to release.yml - Upload version.json as release artifact, include in release assets Phase 5 - API endpoints: - New GET /api/v1/system/update-check endpoint returning version info - OpenAPI registration for update_check path and UpdateCheckResponse - Add schema_version to health check response - Permission: ConfigRead for update-check (same as /system) - Update insta snapshot for health response schema change --- .github/workflows/release.yml | 35 ++++++++ crates/easybot-api/src/openapi.rs | 4 + crates/easybot-api/src/routes/health.rs | 3 + crates/easybot-api/src/routes/mod.rs | 1 + crates/easybot-api/src/routes/update.rs | 88 +++++++++++++++++++ crates/easybot-api/src/server.rs | 8 +- .../snapshots/routes__health_response.snap | 2 + 7 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 crates/easybot-api/src/routes/update.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f64bcfd..bbf987e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,6 +122,34 @@ jobs: name: release-body path: release_body.md + # Generate easybot-version.json for the auto-update system + - name: Generate easybot-version.json + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cat > easybot-version.json <<'EOF' + { + "version": "'"$VERSION"'", + "tag": "v'"$VERSION"'", + "release_date": "$(date +%Y-%m-%d)", + "schema_version": 1, + "requires_db_migration": false, + "migrations": [], + "requires_config_migration": false, + "config_changes": [], + "breaking_changes": [], + "plugin_abi_version": 1, + "min_upgradable_from": "0.0.10" + } + EOF + echo "Generated easybot-version.json for v$VERSION" + + - name: Upload easybot-version.json + uses: actions/upload-artifact@v7 + with: + name: easybot-version + path: easybot-version.json + # Commit version bump and push to main (skipped if no changes — re-run safe) - name: Commit version bump and push to main env: @@ -350,6 +378,12 @@ jobs: name: release-body path: .release-body + - name: Download easybot-version.json + uses: actions/download-artifact@v8 + with: + name: easybot-version + path: . + - name: Create Release uses: softprops/action-gh-release@v3 with: @@ -361,6 +395,7 @@ jobs: files: | easybot-*/easybot-* checksums.txt + easybot-version.json # ── Build and push Docker image ──────────────────── docker: diff --git a/crates/easybot-api/src/openapi.rs b/crates/easybot-api/src/openapi.rs index 3b32a82..57b8498 100644 --- a/crates/easybot-api/src/openapi.rs +++ b/crates/easybot-api/src/openapi.rs @@ -81,6 +81,8 @@ impl Modify for SecurityAddon { routes::admin::admin_login, // System routes::system::system_info, + // Version update check + routes::update::update_check, // Logs routes::logs::log_entries, ), @@ -165,6 +167,8 @@ impl Modify for SecurityAddon { // Error easybot_core::types::error::ApiErrorResponse, easybot_core::types::error::ApiErrorDetail, + // Update check + routes::update::UpdateCheckResponse, // Logs routes::logs::LogQuery, ) diff --git a/crates/easybot-api/src/routes/health.rs b/crates/easybot-api/src/routes/health.rs index 2c15bf9..32c0ef2 100644 --- a/crates/easybot-api/src/routes/health.rs +++ b/crates/easybot-api/src/routes/health.rs @@ -12,6 +12,8 @@ pub struct HealthResponse { pub status: String, #[schema(example = "0.1.0")] pub version: String, + /// 数据库 schema 版本 + pub schema_version: i64, /// 服务运行时间(秒) pub uptime: i64, pub adapters: AdapterSummary, @@ -53,6 +55,7 @@ pub async fn health_check(State(state): State) -> Json "degraded".to_string() }, version: env!("CARGO_PKG_VERSION").to_string(), + schema_version: easybot_core::storage::migration::SCHEMA_VERSION, uptime: state.started_at.elapsed().as_secs() as i64, adapters: AdapterSummary { total: statuses.len(), diff --git a/crates/easybot-api/src/routes/mod.rs b/crates/easybot-api/src/routes/mod.rs index fe37af9..4e7a4b6 100644 --- a/crates/easybot-api/src/routes/mod.rs +++ b/crates/easybot-api/src/routes/mod.rs @@ -11,4 +11,5 @@ pub mod logs; pub mod messages; pub mod sessions; pub mod system; +pub mod update; pub mod ws; diff --git a/crates/easybot-api/src/routes/update.rs b/crates/easybot-api/src/routes/update.rs new file mode 100644 index 0000000..9fe3d1c --- /dev/null +++ b/crates/easybot-api/src/routes/update.rs @@ -0,0 +1,88 @@ +//! 版本更新检查 API +//! +//! 提供 `GET /api/v1/system/update-check` 端点, +//! 返回当前版本和最新可用版本信息。 + +use crate::AppState; +use axum::{Json, extract::State}; +use serde::Serialize; +use utoipa::ToSchema; + +/// 更新检查响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct UpdateCheckResponse { + #[schema(example = "0.0.16")] + pub current_version: String, + /// 数据库 schema 版本 + pub schema_version: i64, + /// 最新可用版本(来自 GitHub API,可能为 None 如果检查失败) + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_version: Option, + /// 最新版本对应的 schema 版本 + pub latest_schema_version: Option, + /// 是否有可用更新 + pub update_available: bool, + /// 是否需要数据库迁移 + pub requires_db_migration: bool, + /// 破坏性变更列表 + #[serde(skip_serializing_if = "Vec::is_empty")] + pub breaking_changes: Vec, + /// 上次检查时间戳(毫秒) + pub last_checked: Option, +} + +/// 检查版本更新 +/// +/// 返回当前版本号和最新可用版本信息(从 GitHub Releases API 获取)。 +/// 用于管理后台显示更新提示和自动化运维。 +#[utoipa::path( + get, + path = "/api/v1/system/update-check", + tag = "System", + responses( + (status = 200, description = "Update availability check result", body = UpdateCheckResponse), + ) +)] +pub async fn update_check(State(_state): State) -> Json { + // GitHub API 调用可能失败(离线/速率限制),降级返回仅版本信息 + let home = + std::path::PathBuf::from(std::env::var("EASYBOT_HOME").unwrap_or_else(|_| ".".to_string())); + let mut updater = easybot_core::updater::Updater::new(home); + + match updater.check_update().await { + Ok(plan) => Json(UpdateCheckResponse { + current_version: env!("CARGO_PKG_VERSION").to_string(), + schema_version: easybot_core::storage::migration::SCHEMA_VERSION, + latest_version: Some(plan.target_version), + latest_schema_version: Some(plan.target_schema_version), + update_available: true, + requires_db_migration: plan.requires_db_migration, + breaking_changes: plan.breaking_changes, + last_checked: Some(chrono::Utc::now().timestamp_millis()), + }), + Err(e) => match &e { + easybot_core::updater::types::UpdateError::AlreadyUpToDate(v) => { + Json(UpdateCheckResponse { + current_version: env!("CARGO_PKG_VERSION").to_string(), + schema_version: easybot_core::storage::migration::SCHEMA_VERSION, + latest_version: Some(v.clone()), + latest_schema_version: None, + update_available: false, + requires_db_migration: false, + breaking_changes: vec![], + last_checked: Some(chrono::Utc::now().timestamp_millis()), + }) + } + _ => Json(UpdateCheckResponse { + current_version: env!("CARGO_PKG_VERSION").to_string(), + schema_version: easybot_core::storage::migration::SCHEMA_VERSION, + latest_version: None, + latest_schema_version: None, + update_available: false, + requires_db_migration: false, + breaking_changes: vec![], + last_checked: None, + }), + }, + } +} diff --git a/crates/easybot-api/src/server.rs b/crates/easybot-api/src/server.rs index f5ac514..be3e671 100644 --- a/crates/easybot-api/src/server.rs +++ b/crates/easybot-api/src/server.rs @@ -145,7 +145,11 @@ impl Server { // API Key management (_, _) if route_path.starts_with("/api-keys") => Permission::ApiKeysManage, // System and logs endpoints require config read - (&Method::GET, _) if route_path == "/system" => Permission::ConfigRead, + (&Method::GET, _) + if route_path == "/system" || route_path == "/system/update-check" => + { + Permission::ConfigRead + } (&Method::GET, _) if route_path == "/logs" => Permission::ConfigRead, // Chats endpoints require adapters read (&Method::GET, _) if route_path.starts_with("/chats") => Permission::AdaptersRead, @@ -343,6 +347,8 @@ pub fn create_router(state: AppState) -> Router { .route("/config", put(routes::config::update_config)) // 系统信息(管理后台概览页) .route("/system", get(routes::system::system_info)) + // 版本更新检查 + .route("/system/update-check", get(routes::update::update_check)) // 日志查询(管理后台日志页) .route("/logs", get(routes::logs::log_entries)) // API Key 管理 diff --git a/crates/easybot-api/tests/snapshots/routes__health_response.snap b/crates/easybot-api/tests/snapshots/routes__health_response.snap index ebb43e4..5f7de84 100644 --- a/crates/easybot-api/tests/snapshots/routes__health_response.snap +++ b/crates/easybot-api/tests/snapshots/routes__health_response.snap @@ -1,5 +1,6 @@ --- source: crates/easybot-api/tests/routes.rs +assertion_line: 86 expression: json --- { @@ -7,6 +8,7 @@ expression: json "connected": 0, "total": 0 }, + "schema_version": 1, "sessions": { "active": 0 }, From a5ca18a0b0b2c0093084ae26c22200bf00daa6a2 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Wed, 22 Jul 2026 03:14:02 +0800 Subject: [PATCH 05/11] test: integration tests + docs sync Phase 6 - Integration tests: - Version comparison edge cases (pre-release, invalid, semver) - Platform detection and asset name generation - Migration on temp SQLite DB (forward, verify, data insert) - Migration rollback and re-apply cycle - Backup manifest file read/write roundtrip Phase 7 - Documentation: - CLAUDE.md: Added migration system and updater module docs - Updated: startup version lock, migration lifecycle, upgrade flow --- CLAUDE.md | 33 ++++ Cargo.lock | 2 + tests/integration/Cargo.toml | 2 + tests/integration/src/lib.rs | 3 + tests/integration/src/updater.rs | 272 +++++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 tests/integration/src/updater.rs diff --git a/CLAUDE.md b/CLAUDE.md index aa114ff..8b4ed70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,39 @@ Adapters (easybot-adapter-*) Telegram · Discord · 飞书 · QQ · WeChat | `docs.html` | `templates/docs_layout.html` + `docs/*.md` + `vendor/` | | `home.html` | `templates/home_layout.html` | +### 版本化迁移系统 + +`crates/easybot-core/src/storage/migration.rs` — 数据库 schema 从"幂等全量建表"升级为"版本化增量迁移"。 + +| 概念 | 说明 | +|---|---| +| `SCHEMA_VERSION` | 当前二进制期望的 schema 版本(编译时常量) | +| `MIGRATIONS` | 所有已注册的迁移数组,按版本递增 | +| `_schema_version` 表 | 记录已执行的迁移历史 | +| `run_migrations()` | 前向迁移引擎(SQLite),迭代未执行的迁移 | +| `run_migrations_pg()` | 同上 + `pg_advisory_lock` 互斥(PostgreSQL 多实例) | +| `rollback_to()` | 反向迁移引擎,执行 rollback_sql | + +**新增 schema 迁移时**:向 `MIGRATIONS` 追加 `Migration` 条目,提供 `sql_sqlite` + `sql_postgres` 双后端 SQL,及对应的 `rollback_sqlite/rollback_postgres`。**禁止修改或删除已发行的迁移条目**。 + +### 自动更新系统 + +`crates/easybot-core/src/updater/` — 完整更新生命周期管理: + +| 模块 | 职责 | +|---|---| +| `types.rs` | `UpdateInfo`, `UpdatePlan`, `UpdateError`, 平台检测, 版本比较 | +| `precheck.rs` | 磁盘空间/权限/Docker/dev 模式/插件兼容性检测 | +| `github.rs` | GitHub Releases API 客户端(缓存 + 速率限制) | +| `download.rs` | 流式下载 + SHA256 校验 | +| `compact.rs` | 备份管理(二进制+DB+config) + 服务单元路径更新 | +| `replace.rs` | 原子二进制替换 + 回滚 | +| `mod.rs` | `Updater` struct 编排完整流程 | + +**升级流程**:`check-update`(GitHub API → 版本对比 → 迁移清单)→ 预检 → 备份 → 下载+SHA256 → 原子替换 → DB 迁移 → 验证 → 清理。失败时自动回滚。 + +**启动时版本锁定**:`main.rs` 中 storage 初始化后校验 `DB.schema_version == SCHEMA_VERSION`,不匹配则拒绝启动并提示运行 `easybot update`。 + ### Crate Layout | Crate | Role | diff --git a/Cargo.lock b/Cargo.lock index 7705b4f..8bf328a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,7 +1717,9 @@ version = "0.0.16" dependencies = [ "easybot-core", "easybot-plugin-sdk", + "semver", "serde_json", + "sqlx", "tempfile", "tokio", "tracing", diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index f57128a..6fc17c1 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -11,6 +11,8 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true serde_json.workspace = true +semver.workspace = true +sqlx.workspace = true tempfile = "3" ureq = "3" diff --git a/tests/integration/src/lib.rs b/tests/integration/src/lib.rs index 2e1156f..c348d2f 100644 --- a/tests/integration/src/lib.rs +++ b/tests/integration/src/lib.rs @@ -7,6 +7,9 @@ #[cfg(test)] mod cli; +#[cfg(test)] +mod updater; + #[cfg(test)] mod tests { use easybot_core::AdapterConfig; diff --git a/tests/integration/src/updater.rs b/tests/integration/src/updater.rs new file mode 100644 index 0000000..519c588 --- /dev/null +++ b/tests/integration/src/updater.rs @@ -0,0 +1,272 @@ +//! Updater 集成测试 +//! +//! 使用临时目录和模拟文件测试更新流程的核心组件。 + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// 获取测试用的临时目录 +fn test_dir() -> &'static PathBuf { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let dir = + std::env::temp_dir().join(format!("easybot_integration_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + dir + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use easybot_core::storage::migration; + use easybot_core::updater::types; + + // ── 版本比较 ── + + #[test] + fn test_version_comparison_semver() { + assert!(types::is_newer_than("0.1.0", "0.0.16")); + assert!(types::is_newer_than("1.0.0", "0.9.99")); + assert!(!types::is_newer_than("0.0.16", "0.1.0")); + assert!(!types::is_newer_than("0.0.16", "0.0.16")); + } + + #[test] + fn test_version_comparison_edge_cases() { + // 预发布版本不视为更新 + assert!(!types::is_newer_than("0.0.16-alpha", "0.0.16")); + // 无效版本保守处理 + assert!(!types::is_newer_than("invalid", "0.0.16")); + assert!(!types::is_newer_than("0.0.16", "invalid")); + } + + #[test] + fn test_version_from_tag() { + assert_eq!(types::version_from_tag("v0.1.0"), Some("0.1.0".into())); + assert_eq!(types::version_from_tag("0.1.0"), None); // 缺 v 前缀 + assert_eq!(types::version_from_tag("v1"), None); // 非完整 semver + } + + #[test] + fn test_version_from_tag_edge_cases() { + assert_eq!(types::version_from_tag(""), None); + assert_eq!(types::version_from_tag("v"), None); + assert_eq!(types::version_from_tag("vabc"), None); + assert_eq!( + types::version_from_tag("v0.0.16-rc1"), + Some("0.0.16-rc1".into()) + ); + } + + // ── 平台检测 ── + + #[test] + fn test_current_target_triple() { + let triple = types::current_target_triple().expect("Should detect platform"); + // 验证格式:-- + let parts: Vec<&str> = triple.split('-').collect(); + assert!( + parts.len() >= 3, + "Triple should have at least 3 parts: {}", + triple + ); + // 验证是已知目标 + let valid = + triple.contains("linux") || triple.contains("apple") || triple.contains("windows"); + assert!(valid, "Triple should match a known OS: {}", triple); + } + + #[test] + fn test_current_asset_name() { + let name = types::current_asset_name().expect("Should generate asset name"); + assert!( + name.starts_with("easybot-"), + "Asset should start with 'easybot-'" + ); + // 验证包含平台标识 + assert!( + name.len() > "easybot-".len(), + "Asset should contain platform info" + ); + } + + // ── 语义版本解析 ── + + #[test] + fn test_semver_parse_valid() { + assert!(semver::Version::parse("0.0.16").is_ok()); + assert!(semver::Version::parse("1.0.0").is_ok()); + assert!(semver::Version::parse("0.1.0-alpha").is_ok()); + assert!(semver::Version::parse("255.255.65535").is_ok()); + } + + #[test] + fn test_semver_parse_invalid() { + assert!(semver::Version::parse("0.0").is_err()); + assert!(semver::Version::parse("abc").is_err()); + assert!(semver::Version::parse("").is_err()); + assert!(semver::Version::parse("v0.0.1").is_err()); // v 前缀非法 + } + + // ── 迁移测试(使用临时 SQLite 数据库)── + + /// 创建临时 SQLite 数据库路径 + fn temp_db_path() -> PathBuf { + test_dir().join(format!("test_migration_{}.db", rand_id())) + } + + fn rand_id() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + #[tokio::test] + async fn test_migration_on_temp_db() { + let db_path = temp_db_path(); + + // 创建连接池 + let pool = easybot_core::storage::sqlite::create_pool(&db_path) + .await + .expect("Should create SQLite pool"); + + // 运行迁移 + migration::run_migrations(&pool) + .await + .expect("Migration should succeed"); + + // 验证版本 + let version = migration::get_current_version(&pool) + .await + .expect("Should get version"); + assert_eq!( + version, + migration::SCHEMA_VERSION, + "DB schema version should match binary SCHEMA_VERSION" + ); + + // 验证表存在 + let table_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('sessions', 'messages', 'api_keys', '_schema_version')" + ) + .fetch_one(&pool) + .await + .unwrap_or(0); + assert_eq!(table_count, 4, "All 4 tables should exist"); + + // 验证可以插入和查询数据 + sqlx::query( + "INSERT INTO _schema_version (version, applied_at, description) VALUES (999, 1234567890, 'test entry')" + ) + .execute(&pool) + .await + .expect("Should insert test entry"); + + // 清理 + let _ = std::fs::remove_file(&db_path); + drop(pool); + } + + #[tokio::test] + async fn test_migration_rollback_on_temp_db() { + let db_path = temp_db_path(); + let pool = easybot_core::storage::sqlite::create_pool(&db_path) + .await + .expect("Should create pool"); + + // 前向迁移 + migration::run_migrations(&pool).await.expect("Migration"); + assert_eq!( + migration::get_current_version(&pool).await.unwrap(), + migration::SCHEMA_VERSION + ); + + // 回滚到 0 + migration::rollback_to(&pool, 0).await.expect("Rollback"); + assert_eq!(migration::get_current_version(&pool).await.unwrap(), 0); + + // 验证表被删除 + let has_sessions: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", + ) + .fetch_one(&pool) + .await + .unwrap_or(false); + assert!( + !has_sessions, + "sessions table should be gone after rollback" + ); + + // 再次前向迁移 + migration::run_migrations(&pool) + .await + .expect("Re-migration"); + assert_eq!( + migration::get_current_version(&pool).await.unwrap(), + migration::SCHEMA_VERSION + ); + + let _ = std::fs::remove_file(&db_path); + drop(pool); + } + + // ── 备份清单文件读写测试 ── + + #[tokio::test] + async fn test_manifest_file_read_write() { + let dir = test_dir().join(format!("manifest_test_{}", rand_id())); + std::fs::create_dir_all(&dir).expect("Should create temp dir"); + + // 直接测试备份清单文件的序列化/反序列化 + let manifest = serde_json::json!({ + "timestamp": 1234567890, + "from_version": "0.0.16", + "to_version": "0.1.0", + "from_schema_version": 1, + "to_schema_version": 2, + "binary_backup": null, + "db_backup": null, + "config_backup": null, + "migrations_applied": [2] + }); + + let manifest_path = dir.join(".update_manifest.json"); + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .expect("Should write manifest"); + + // 验证可读取 + let content = std::fs::read_to_string(&manifest_path).expect("Should read manifest"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("Should parse"); + assert_eq!(parsed["from_version"], "0.0.16"); + assert_eq!(parsed["to_version"], "0.1.0"); + assert_eq!(parsed["migrations_applied"][0], 2); + + let _ = std::fs::remove_dir_all(&dir); + } + + // ── 错误类型测试 ── + + #[test] + fn test_update_error_display() { + let err = types::UpdateError::UnsupportedPlatform; + assert_eq!(format!("{}", err), "Unsupported platform for auto-update"); + + let err = types::UpdateError::AlreadyUpToDate("0.0.16".into()); + assert!(format!("{}", err).contains("0.0.16")); + + let err = types::UpdateError::ChecksumMismatch { + expected: "abc123".into(), + actual: "def456".into(), + }; + assert!(format!("{}", err).contains("abc123")); + assert!(format!("{}", err).contains("def456")); + + let err = types::UpdateError::RateLimited; + assert!(format!("{}", err).contains("GITHUB_TOKEN")); + } +} From 21a2fa279cc139624a8d7ee1bc65d7789a8db192 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Wed, 22 Jul 2026 03:29:10 +0800 Subject: [PATCH 06/11] chore: complete remaining tasks - wiremock tests, API test, user guide - Task 6.6: GitHub API wiremock tests (4 test cases: latest release, rate limit 403, not found 404, checksums parsing) - Task 6.8: API endpoint test for /api/v1/system/update-check - Task 7.3: Update user-guide.md with upgrade commands section --- crates/easybot-api/tests/routes.rs | 15 +++ crates/easybot-core/src/updater/github.rs | 157 +++++++++++++++++++++- docs/01 user-guide.md | 44 +++++- 3 files changed, 211 insertions(+), 5 deletions(-) diff --git a/crates/easybot-api/tests/routes.rs b/crates/easybot-api/tests/routes.rs index cf9b479..78d431b 100644 --- a/crates/easybot-api/tests/routes.rs +++ b/crates/easybot-api/tests/routes.rs @@ -93,6 +93,21 @@ async fn test_health_no_auth_needed() { assert_eq!(status, StatusCode::OK); } +// ── 版本更新检查 ── + +#[tokio::test] +async fn test_update_check_returns_structure() { + let (state, key) = common::test_app_state().await; + let (status, json) = get(state, "/api/v1/system/update-check", Some(&key)).await; + + // 即使 GitHub API 不可达,也应返回 200 并包含版本信息 + assert_eq!(status, StatusCode::OK); + assert_eq!(json["current_version"], "0.0.16"); + assert!(json["schema_version"].is_number()); + assert!(json["update_available"].is_boolean()); + assert!(json["requires_db_migration"].is_boolean()); +} + // ── 认证测试 ── #[tokio::test] diff --git a/crates/easybot-core/src/updater/github.rs b/crates/easybot-core/src/updater/github.rs index 27dc035..936484f 100644 --- a/crates/easybot-core/src/updater/github.rs +++ b/crates/easybot-core/src/updater/github.rs @@ -18,6 +18,7 @@ pub struct GitHubClient { client: reqwest::Client, owner: String, repo: String, + base_url: String, #[allow(dead_code)] token: Option, @@ -32,6 +33,11 @@ impl GitHubClient { /// /// 自动检查 `GITHUB_TOKEN` 环境变量以提升速率限制。 pub fn new(owner: &str, repo: &str) -> Self { + Self::with_base_url(owner, repo, GITHUB_API_BASE) + } + + /// 创建带自定义 base URL 的客户端(用于测试) + fn with_base_url(owner: &str, repo: &str, base_url: &str) -> Self { let token = std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()); let mut headers = reqwest::header::HeaderMap::new(); @@ -60,6 +66,7 @@ impl GitHubClient { client, owner: owner.to_string(), repo: repo.to_string(), + base_url: base_url.to_string(), token, release_cache: None, manifest_cache: None, @@ -78,7 +85,7 @@ impl GitHubClient { let url = format!( "{}/repos/{}/{}/releases/latest", - GITHUB_API_BASE, self.owner, self.repo + self.base_url, self.owner, self.repo ); let resp = self.client.get(&url).send().await?; @@ -123,7 +130,7 @@ impl GitHubClient { // 先从 release 中找到 version.json 资产 let release_url = format!( "{}/repos/{}/{}/releases/tags/{}", - GITHUB_API_BASE, self.owner, self.repo, tag + self.base_url, self.owner, self.repo, tag ); let resp = self.client.get(&release_url).send().await?; @@ -137,7 +144,6 @@ impl GitHubClient { let release: ReleaseInfo = resp.json().await?; - // 查找 easybot-version.json asset let asset_url = release .assets .iter() @@ -203,7 +209,7 @@ impl GitHubClient { // 获取 checksums.txt 的下载 URL let release_url = format!( "{}/repos/{}/{}/releases/tags/{}", - GITHUB_API_BASE, self.owner, self.repo, tag + self.base_url, self.owner, self.repo, tag ); let resp = self.client.get(&release_url).send().await?; @@ -402,4 +408,147 @@ mod tests { assert_eq!(client.owner, "EasyIndie"); assert_eq!(client.repo, "EasyBot"); } + + #[tokio::test] + async fn test_github_api_latest_release_mock() { + // 启动 wiremock 服务器 + let mock_server = wiremock::MockServer::start().await; + + // Mock 最新 release 响应 + let release_body = serde_json::json!({ + "tag_name": "v0.1.0", + "html_url": "https://github.com/EasyIndie/EasyBot/releases/tag/v0.1.0", + "body": "Release notes", + "published_at": "2026-07-22T00:00:00Z", + "assets": [ + { + "name": "easybot-x86_64-unknown-linux-musl", + "size": 12345678, + "browser_download_url": "https://github.com/EasyIndie/EasyBot/releases/download/v0.1.0/easybot-x86_64-unknown-linux-musl" + }, + { + "name": "checksums.txt", + "size": 512, + "browser_download_url": "https://github.com/EasyIndie/EasyBot/releases/download/v0.1.0/checksums.txt" + } + ] + }); + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/repos/EasyIndie/EasyBot/releases/latest", + )) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(&release_body) + .insert_header("Content-Type", "application/json"), + ) + .mount(&mock_server) + .await; + + // 创建指向 mock 服务器的客户端 + let mut client = GitHubClient::with_base_url("EasyIndie", "EasyBot", &mock_server.uri()); + + // 测试获取最新 release + let release = client.latest_release().await.expect("Should fetch release"); + assert_eq!(release.tag_name, "v0.1.0"); + assert_eq!(release.assets.len(), 2); + assert_eq!(release.assets[0].name, "easybot-x86_64-unknown-linux-musl"); + } + + #[tokio::test] + async fn test_github_api_rate_limit() { + let mock_server = wiremock::MockServer::start().await; + + // Mock 403 响应(速率限制) + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/repos/EasyIndie/EasyBot/releases/latest", + )) + .respond_with(wiremock::ResponseTemplate::new(403)) + .mount(&mock_server) + .await; + + let mut client = GitHubClient::with_base_url("EasyIndie", "EasyBot", &mock_server.uri()); + + let result = client.latest_release().await; + assert!(result.is_err()); + match result.unwrap_err() { + UpdateError::RateLimited => {} // 预期的错误 + e => panic!("Expected RateLimited, got: {}", e), + } + } + + #[tokio::test] + async fn test_github_api_not_found() { + let mock_server = wiremock::MockServer::start().await; + + // Mock 404 响应 + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/repos/EasyIndie/EasyBot/releases/latest", + )) + .respond_with(wiremock::ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let mut client = GitHubClient::with_base_url("EasyIndie", "EasyBot", &mock_server.uri()); + + let result = client.latest_release().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_github_api_checksums_mock() { + let mock_server = wiremock::MockServer::start().await; + + let checksums_content = + "abc123def456 easybot-x86_64-unknown-linux-musl\n789abc012def checksums.txt\n"; + + // Mock release 列表返回 + let release_body = serde_json::json!({ + "tag_name": "v0.1.0", + "html_url": "https://github.com/EasyIndie/EasyBot/releases/tag/v0.1.0", + "body": "", + "assets": [ + { + "name": "checksums.txt", + "size": 512, + "browser_download_url": format!("{}/assets/checksums.txt", mock_server.uri()) + } + ] + }); + + // Mock release by tag + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path( + "/repos/EasyIndie/EasyBot/releases/tags/v0.1.0", + )) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(&release_body) + .insert_header("Content-Type", "application/json"), + ) + .mount(&mock_server) + .await; + + // Mock checksums.txt asset + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/assets/checksums.txt")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(checksums_content)) + .mount(&mock_server) + .await; + + let mut client = GitHubClient::with_base_url("EasyIndie", "EasyBot", &mock_server.uri()); + + let checksums = client + .checksums("v0.1.0") + .await + .expect("Should fetch checksums"); + assert_eq!(checksums.len(), 2); + assert_eq!( + checksums.get("easybot-x86_64-unknown-linux-musl").unwrap(), + "abc123def456" + ); + } } diff --git a/docs/01 user-guide.md b/docs/01 user-guide.md index fe80e51..7d819c6 100644 --- a/docs/01 user-guide.md +++ b/docs/01 user-guide.md @@ -1001,4 +1001,46 @@ wscat -c ws://localhost:8080/api/v1/ws --- -*最后更新:2026-07-20 · EasyBot v0.0.15* +## 6. 升级更新 + +### 6.1 自动更新(二进制部署) + +```bash +# 检查可用更新(显示版本号、数据库迁移、破坏性变更) +easybot check-update + +# 执行更新(自动备份 + 二进制替换 + 数据库迁移) +easybot update + +# 跳过确认提示(用于自动化脚本) +easybot update --yes + +# 回滚到上一个版本 +easybot rollback +``` + +更新流程说明: +1. `check-update`:对比当前版本与 GitHub 最新发布版本,显示迁移计划和破坏性变更 +2. `update`:预检(磁盘/权限/Docker 检测)→ 备份二进制+数据库+配置 → 下载新二进制 + SHA256 校验 → 原子替换 → 服务路径更新 → 验证 +3. 任何步骤失败自动回滚 +4. 更新完成后需重启服务生效 + +### 6.2 Docker 部署更新 + +```bash +docker compose pull && docker compose up -d +``` + +### 6.3 升级后重启 + +| 部署方式 | 重启命令 | +|---------|---------| +| systemd | `sudo systemctl restart easybot` | +| launchd | `./easybot.sh restart` | +| 管理脚本 | `./easybot.sh restart` | +| Docker | `docker compose restart` | +| 直接运行 | 重新启动 `easybot` 进程 | + +--- + +*最后更新:2026-07-22 · EasyBot v0.0.16* From 7738c9a9fd192b088cd91fae253f27cf21da55b5 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Wed, 22 Jul 2026 03:38:38 +0800 Subject: [PATCH 07/11] fix: windows CI dead_code warnings in updater - Add #[allow(dead_code)] to cfg-gated functions on non-Unix - available_space() on Windows (precheck.rs) - set_executable() on Windows (replace.rs) --- crates/easybot-core/src/updater/precheck.rs | 1 + crates/easybot-core/src/updater/replace.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/easybot-core/src/updater/precheck.rs b/crates/easybot-core/src/updater/precheck.rs index e82cb0d..55afe47 100644 --- a/crates/easybot-core/src/updater/precheck.rs +++ b/crates/easybot-core/src/updater/precheck.rs @@ -172,6 +172,7 @@ fn available_space(path: &Path) -> Result { } #[cfg(not(unix))] +#[allow(dead_code)] fn available_space(_path: &Path) -> Result { // Windows: 使用 GetDiskFreeSpaceEx Ok(1_000_000_000) // 保守返回 1GB diff --git a/crates/easybot-core/src/updater/replace.rs b/crates/easybot-core/src/updater/replace.rs index 1c8902c..5f62b2a 100644 --- a/crates/easybot-core/src/updater/replace.rs +++ b/crates/easybot-core/src/updater/replace.rs @@ -159,6 +159,7 @@ fn set_executable(path: &Path) -> Result<(), UpdateError> { } #[cfg(not(unix))] +#[allow(dead_code)] fn set_executable(_path: &Path) -> Result<(), UpdateError> { // Windows 没有 Unix 风格的可执行位 Ok(()) From 5342d5237b5a7f74dcd965fc41620132a1cbb123 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Sun, 26 Jul 2026 18:06:19 +0800 Subject: [PATCH 08/11] fix: address auto-update review issues --- crates/easybot-core/src/storage/migration.rs | 16 ++++++++++++++++ crates/easybot-core/src/updater/compact.rs | 20 -------------------- crates/easybot-core/src/updater/mod.rs | 4 ++-- crates/easybot-core/src/updater/replace.rs | 4 ++-- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/crates/easybot-core/src/storage/migration.rs b/crates/easybot-core/src/storage/migration.rs index 6283f9e..cfe8d31 100644 --- a/crates/easybot-core/src/storage/migration.rs +++ b/crates/easybot-core/src/storage/migration.rs @@ -163,9 +163,25 @@ CREATE TABLE IF NOT EXISTS messages ( CREATE INDEX IF NOT EXISTS idx_messages_sk ON messages(session_key, timestamp DESC); CREATE INDEX IF NOT EXISTS idx_messages_pc ON messages(platform, chat_id, timestamp DESC); CREATE INDEX IF NOT EXISTS idx_messages_ct ON messages(created_at); + +CREATE TABLE IF NOT EXISTS api_keys ( + id VARCHAR(255) PRIMARY KEY, + name TEXT NOT NULL, + prefix VARCHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT, + last_used_at BIGINT, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + permissions JSONB NOT NULL DEFAULT '[]', + event_filters JSONB NOT NULL DEFAULT '[]', + hash TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_created ON api_keys(created_at DESC); "; const V1_ROLLBACK_POSTGRES: &str = " +DROP TABLE IF EXISTS api_keys; DROP TABLE IF EXISTS messages; DROP TABLE IF EXISTS sessions; "; diff --git a/crates/easybot-core/src/updater/compact.rs b/crates/easybot-core/src/updater/compact.rs index f2a5d4d..8fec38c 100644 --- a/crates/easybot-core/src/updater/compact.rs +++ b/crates/easybot-core/src/updater/compact.rs @@ -208,26 +208,6 @@ impl BackupManager { Ok(()) } - - /// 删除所有备份文件 - pub async fn cleanup(manifest: &UpdateManifest) -> Result<(), UpdateError> { - let paths = [ - manifest.binary_backup.as_deref(), - manifest.db_backup.as_deref(), - manifest.config_backup.as_deref(), - ]; - - for path in paths.iter().flatten() { - let p = Path::new(path); - if p.exists() - && let Err(e) = tokio::fs::remove_file(p).await - { - tracing::warn!("Failed to remove backup {}: {}", path, e); - } - } - - Ok(()) - } } // ══════════════════════════════════════════════════════════════════ diff --git a/crates/easybot-core/src/updater/mod.rs b/crates/easybot-core/src/updater/mod.rs index 3609971..a4de5b9 100644 --- a/crates/easybot-core/src/updater/mod.rs +++ b/crates/easybot-core/src/updater/mod.rs @@ -175,8 +175,8 @@ impl Updater { Ok(_) => { tracing::info!("New binary verification passed"); - // 清理备份 - let _ = BackupManager::cleanup(&manifest).await; + // Keep backups and the manifest so `easybot rollback` remains available + // after a successful update. // 清理临时下载文件 let _ = tokio::fs::remove_file(&temp_path).await; diff --git a/crates/easybot-core/src/updater/replace.rs b/crates/easybot-core/src/updater/replace.rs index 5f62b2a..b25c79c 100644 --- a/crates/easybot-core/src/updater/replace.rs +++ b/crates/easybot-core/src/updater/replace.rs @@ -113,12 +113,12 @@ fn create_backup(exe_path: &Path, version: &str) -> Result /// 验证新二进制能否正常启动 /// -/// 通过运行 `{new_bin} --check-update` 检测退出码。 +/// 通过运行 `{new_bin} check-update` 检测退出码。 pub async fn verify_binary(bin_path: &Path) -> Result<(), UpdateError> { let bin = bin_path.to_path_buf(); tokio::task::spawn_blocking(move || { let output = std::process::Command::new(&bin) - .arg("--check-update") + .arg("check-update") .output() .map_err(|e| { UpdateError::VerificationFailed(format!("Cannot start new binary: {}", e)) From 31e193876037771f9881ec603f5b1044f397eb6a Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Sun, 26 Jul 2026 18:49:07 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E8=BF=AD=E4=BB=A3=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=80=97=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .githooks/pre-commit | 19 +++++++------- .githooks/pre-push | 49 +++++++++++++++-------------------- Makefile | 58 ++++++++++++++++++------------------------ scripts/verify-push.sh | 53 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 71 deletions(-) create mode 100755 scripts/verify-push.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 03a7157..c69f0da 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,9 +1,9 @@ #!/bin/sh # EasyBot pre-commit hook -# 安装: cargo-husky 在 cargo build 时自动配置 +# 安装: bin/build.rs 在 cargo build/check/test 时自动配置 core.hooksPath # -# 提交前自动检查 cargo fmt 和 cargo clippy,不通过则阻止提交。 -# fmt 会尝试自动格式化后再阻止,clippy 直接阻止。 +# 提交前只运行快速检查,不通过则阻止提交。 +# 更重的 clippy/test 放到 pre-push,完整验收请运行 make verify。 # # 跳过检查(不推荐): git commit --no-verify @@ -15,7 +15,7 @@ if command -v cargo >/dev/null 2>&1; then elif command -v wsl >/dev/null 2>&1; then CARGO="wsl cargo" else - echo ">>> 跳过 cargo fmt/clippy(cargo 不可用)" + echo ">>> 跳过 Rust 检查(cargo 不可用)" exit 0 fi @@ -33,13 +33,12 @@ if git diff --cached --name-only --diff-filter=ACMR | grep -q '\.rs$'; then fi echo ">>> cargo fmt 通过" - # ── cargo clippy ── - echo ">>> 检查 cargo clippy..." - if ! $CARGO clippy --all-targets -- -D warnings 2>/dev/null; then + # ── cargo check ── + echo ">>> 快速编译检查 cargo check..." + if ! $CARGO check --workspace --features "default,plugin-system"; then echo "" - echo ">>> cargo clippy 未通过。请修复以上 warning/error 后重试。" - echo " 快速查看: cargo clippy --all-targets" + echo ">>> cargo check 未通过。请修复以上 error 后重试。" exit 1 fi - echo ">>> cargo clippy 通过" + echo ">>> cargo check 通过" fi diff --git a/.githooks/pre-push b/.githooks/pre-push index 7c49f08..07ca729 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,10 +1,9 @@ #!/bin/sh # EasyBot pre-push hook -# 安装: cargo-husky 在 cargo build 时自动配置 +# 安装: bin/build.rs 在 cargo build/check/test 时自动配置 core.hooksPath # -# push 前自动运行验收脚本,失败则阻止推送,避免 CI 不通过。 -# 完整模式(默认):bash scripts/verify.sh -# 快速模式:设置 git config easybot.pre-push-fast true +# push 前自动运行中等强度门禁,失败则阻止推送,避免核心 CI 失败。 +# 完整模式:设置 git config --bool easybot.pre-push-full true set -e @@ -13,34 +12,28 @@ if [ -z "$PROJECT_DIR" ]; then echo ">>> 不在 git 仓库中,跳过验收" exit 0 fi -SCRIPT="$PROJECT_DIR/scripts/verify.sh" + +FULL=$(git config --bool easybot.pre-push-full 2>/dev/null || echo false) + +if [ "$FULL" = true ]; then + SCRIPT="$PROJECT_DIR/scripts/verify.sh" + LABEL="完整验收" +else + SCRIPT="$PROJECT_DIR/scripts/verify-push.sh" + LABEL="push 门禁" +fi if [ ! -f "$SCRIPT" ]; then - echo ">>> 找不到 $SCRIPT,跳过验收" + echo ">>> 找不到 $SCRIPT,跳过 $LABEL" exit 0 fi -FAST=$(git config --bool easybot.pre-push-fast 2>/dev/null || echo false) - -if [ "$FAST" = true ]; then - echo ">>> [快速模式] 运行验收 (check + matrix + build + test)..." - if bash "$SCRIPT" --fast; then - echo ">>> 验收通过,正在推送..." - exit 0 - else - echo "" - echo "❌ 验收未通过,推送已阻止。修复后重试。" - exit 1 - fi +echo ">>> 运行 $LABEL..." +if bash "$SCRIPT"; then + echo ">>> $LABEL 通过,正在推送..." + exit 0 else - echo ">>> [完整模式] 运行验收 (全部 8 步: fmt + clippy + check + matrix + build + test)..." - echo " (如嫌太慢可切换到快速模式: git config --bool easybot.pre-push-fast true)" - if bash "$SCRIPT"; then - echo ">>> 验收通过,正在推送..." - exit 0 - else - echo "" - echo "❌ 验收未通过,推送已阻止。修复后重试。" - exit 1 - fi + echo "" + echo "pre-push 未通过,推送已阻止。修复后重试。" + exit 1 fi diff --git a/Makefile b/Makefile index af4ac20..eddec61 100644 --- a/Makefile +++ b/Makefile @@ -2,58 +2,50 @@ .DEFAULT_GOAL := help -.PHONY: help setup verify verify-fast test test-full lint fmt \ - run run-init run-fresh watch check clean +CARGO ?= cargo +FEATURES ?= default,plugin-system + +.PHONY: help setup verify verify-push check lint fmt test run clean help: ## 显示此帮助 - @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | sort | \ + @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | \ awk 'BEGIN {FS = ":.*## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' -# ── CI / 验收 ───────────────────────────────── +# ── 环境 ───────────────────────────────────── -verify: ## 运行完整验收(与 CI 一致) - bash scripts/verify.sh +setup: ## 初始化本地开发环境 + git config core.hooksPath .githooks -verify-fast: ## 快速验收(跳过 clippy + fmt) - bash scripts/verify.sh --fast +# ── Hook / CI 验收 ─────────────────────────── -test: ## 运行所有测试 - cargo test --workspace +verify: ## 运行完整验收(与 CI 一致) + bash scripts/verify.sh -test-full: ## 运行全部测试(含 plugin-system feature) - cargo test --workspace --features "default,plugin-system" +verify-push: ## push 前门禁(clippy + build + test,快于完整验收) + bash scripts/verify-push.sh # ── 代码质量 ────────────────────────────────── -lint: ## 代码规范检查(fmt + clippy) - cargo fmt --all --check - cargo clippy --workspace --features "default,plugin-system" --all-targets -- -D warnings +check: ## 快速编译检查(同 pre-commit) + $(CARGO) fmt --all --check + $(CARGO) check --workspace --features "$(FEATURES)" fmt: ## 自动格式化代码 - cargo fmt --all + $(CARGO) fmt --all -check: ## 快速编译检查 - cargo check +lint: ## 代码规范检查(fmt + clippy) + $(CARGO) fmt --all --check + $(CARGO) clippy --workspace --features "$(FEATURES)" --all-targets -- -D warnings + +test: ## 运行所有测试 + $(CARGO) test --workspace --features "$(FEATURES)" # ── 本地开发 ────────────────────────────────── DEBUG_FLAG ?= --debug run: ## 编译并启动(默认 --debug,make run DEBUG= 可去掉) - cargo run -- $(DEBUG_FLAG) - -run-init: ## 初始化隔离目录后启动(不影响 ~/.easybot/) - @test -d /tmp/easybot-dev || cargo run -- --dir /tmp/easybot-dev --init - cargo run -- --dir /tmp/easybot-dev $(DEBUG_FLAG) - -run-fresh: ## 清理隔离目录后全新初始化并启动 - rm -rf /tmp/easybot-dev - cargo run -- --dir /tmp/easybot-dev --init - cargo run -- --dir /tmp/easybot-dev $(DEBUG_FLAG) - -watch: ## Watch 模式:改代码自动重编重启(自动安装依赖) - @command -v cargo-watch >/dev/null 2>&1 || ( echo "📦 正在安装 cargo-watch ..."; cargo install cargo-watch --quiet ) - cargo watch -x 'run -- --debug' + $(CARGO) run -- $(DEBUG_FLAG) clean: ## 清理编译产物 - cargo clean + $(CARGO) clean diff --git a/scripts/verify-push.sh b/scripts/verify-push.sh new file mode 100755 index 0000000..ab115ee --- /dev/null +++ b/scripts/verify-push.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# EasyBot push 前门禁 +# +# 目标:覆盖最常见的 CI 失败点,同时避免每次 push 都跑完整 feature matrix。 +# 完整 CI 等价验收仍使用: bash scripts/verify.sh --locked + +set -euo pipefail + +PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || echo '')" +if [ -z "$PROJECT_DIR" ]; then + echo "ERROR: 不在 git 仓库中" >&2 + exit 1 +fi +cd "$PROJECT_DIR" + +if command -v cargo >/dev/null 2>&1; then + CARGO="cargo" +elif command -v wsl >/dev/null 2>&1; then + CARGO="wsl cargo" +else + echo "ERROR: cargo not found (tried 'cargo' and 'wsl cargo')" >&2 + exit 1 +fi + +if $CARGO nextest --version >/dev/null 2>&1; then + TEST_RUNNER="$CARGO nextest run" +else + TEST_RUNNER="$CARGO test" +fi + +run_step() { + name="$1" + shift + echo "" + echo ">>> $name" + "$@" +} + +run_step "cargo fmt --check" \ + $CARGO fmt --all --check + +run_step "cargo clippy (workspace + plugin-system)" \ + $CARGO clippy --workspace --features "default,plugin-system" --all-targets --locked -- -D warnings + +run_step "cargo build -p mock-adapter" \ + $CARGO build -p mock-adapter --locked + +run_step "tests (workspace + plugin-system)" \ + $TEST_RUNNER --workspace --features "default,plugin-system" --locked + +echo "" +echo ">>> push 门禁通过" From 37c4be8659404470441195906d60b742cfb01158 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Sun, 26 Jul 2026 19:24:03 +0800 Subject: [PATCH 10/11] ci: verify auto-update platform matrix --- .github/workflows/ci.yml | 17 +++-- .github/workflows/release.yml | 25 +++++++ crates/easybot-core/src/updater/types.rs | 91 +++++++++++++++++++++--- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03badb3..e6ee3c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,27 +86,36 @@ jobs: - name: Check (${{ matrix.features }}) run: cargo check --workspace ${{ matrix.features }} --locked - # ── Cross-platform check (Linux arm64, macOS arm64, Windows x64/arm64) ── + # ── Cross-platform check (Linux/macOS/Windows × x64/arm64) ── cross-check: runs-on: ${{ matrix.os }} strategy: matrix: include: - - os: ubuntu-24.04-arm + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-musl - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin - os: windows-latest + target: x86_64-pc-windows-msvc - os: windows-11-arm + target: aarch64-pc-windows-msvc fail-fast: false steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust with: + target: ${{ matrix.target }} install-protoc: auto - cache-shared-key: cross-check-${{ runner.os }}-${{ runner.arch }} + cache-shared-key: cross-check-${{ matrix.target }} cache-save-if: ${{ github.ref == 'refs/heads/main' }} - name: Check - run: cargo check --workspace --features "default,plugin-system" --locked + run: cargo check --workspace --features "default,plugin-system" --target ${{ matrix.target }} --locked # ── Code coverage ─────────────────────────────────────────────────── coverage: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbf987e..2fdf697 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -372,6 +372,31 @@ jobs: fi done + - name: Verify release asset matrix + shell: bash + run: | + set -euo pipefail + expected=( + "easybot-x86_64-unknown-linux-musl" + "easybot-aarch64-unknown-linux-musl" + "easybot-x86_64-apple-darwin" + "easybot-aarch64-apple-darwin" + "easybot-x86_64-pc-windows-msvc.exe" + "easybot-aarch64-pc-windows-msvc.exe" + ) + + for asset in "${expected[@]}"; do + matches=(easybot-*/"$asset") + if [ ! -f "${matches[0]}" ]; then + echo "::error::Missing release asset: $asset" + exit 1 + fi + if ! grep -qE "^[0-9a-f]{64}[[:space:]]+$asset$" checksums.txt; then + echo "::error::Missing checksum entry for: $asset" + exit 1 + fi + done + - name: Download release body uses: actions/download-artifact@v8 with: diff --git a/crates/easybot-core/src/updater/types.rs b/crates/easybot-core/src/updater/types.rs index be6a965..bb4b88c 100644 --- a/crates/easybot-core/src/updater/types.rs +++ b/crates/easybot-core/src/updater/types.rs @@ -6,6 +6,16 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// EasyBot officially published binary targets. +pub const SUPPORTED_TARGETS: &[&str] = &[ + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", +]; + /// 目标平台对应的 Release artifact 名称 /// /// 映射规则与 `.github/workflows/release.yml` 的 build-matrix 一致。 @@ -20,13 +30,18 @@ pub struct PlatformAsset { /// /// 当平台不支持自动更新时返回 `UnsupportedPlatform` 错误。 pub fn current_target_triple() -> Result<&'static str, UpdateError> { - match (std::env::consts::ARCH, std::env::consts::OS) { - ("x86_64", "linux") => Ok("x86_64-unknown-linux-musl"), - ("aarch64", "linux") => Ok("aarch64-unknown-linux-musl"), - ("x86_64", "macos") => Ok("x86_64-apple-darwin"), - ("aarch64", "macos") => Ok("aarch64-apple-darwin"), - ("x86_64", "windows") => Ok("x86_64-pc-windows-msvc"), - ("aarch64", "windows") => Ok("aarch64-pc-windows-msvc"), + target_triple_for(std::env::consts::ARCH, std::env::consts::OS) +} + +/// Map Rust runtime architecture and OS names to EasyBot release targets. +pub fn target_triple_for(arch: &str, os: &str) -> Result<&'static str, UpdateError> { + match (arch, os) { + ("x86_64", "linux") => Ok(SUPPORTED_TARGETS[0]), + ("aarch64", "linux") => Ok(SUPPORTED_TARGETS[1]), + ("x86_64", "macos") => Ok(SUPPORTED_TARGETS[2]), + ("aarch64", "macos") => Ok(SUPPORTED_TARGETS[3]), + ("x86_64", "windows") => Ok(SUPPORTED_TARGETS[4]), + ("aarch64", "windows") => Ok(SUPPORTED_TARGETS[5]), _ => Err(UpdateError::UnsupportedPlatform), } } @@ -36,11 +51,16 @@ pub fn current_target_triple() -> Result<&'static str, UpdateError> { /// 例: `easybot-x86_64-unknown-linux-musl`(Windows 追加 `.exe`) pub fn current_asset_name() -> Result { let triple = current_target_triple()?; - Ok(if cfg!(target_os = "windows") { - format!("easybot-{}.exe", triple) + Ok(asset_name_for_target(triple)) +} + +/// Return the expected GitHub Release asset name for a supported target. +pub fn asset_name_for_target(target_triple: &str) -> String { + if target_triple.contains("windows") { + format!("easybot-{}.exe", target_triple) } else { - format!("easybot-{}", triple) - }) + format!("easybot-{}", target_triple) + } } /// 版本比较 @@ -304,6 +324,55 @@ mod tests { ); } + #[test] + fn test_supported_platform_matrix() { + let cases = [ + ( + "x86_64", + "linux", + "x86_64-unknown-linux-musl", + "easybot-x86_64-unknown-linux-musl", + ), + ( + "aarch64", + "linux", + "aarch64-unknown-linux-musl", + "easybot-aarch64-unknown-linux-musl", + ), + ( + "x86_64", + "macos", + "x86_64-apple-darwin", + "easybot-x86_64-apple-darwin", + ), + ( + "aarch64", + "macos", + "aarch64-apple-darwin", + "easybot-aarch64-apple-darwin", + ), + ( + "x86_64", + "windows", + "x86_64-pc-windows-msvc", + "easybot-x86_64-pc-windows-msvc.exe", + ), + ( + "aarch64", + "windows", + "aarch64-pc-windows-msvc", + "easybot-aarch64-pc-windows-msvc.exe", + ), + ]; + + assert_eq!(SUPPORTED_TARGETS.len(), cases.len()); + for (arch, os, target, asset) in cases { + assert_eq!(target_triple_for(arch, os).unwrap(), target); + assert_eq!(asset_name_for_target(target), asset); + assert!(SUPPORTED_TARGETS.contains(&target)); + } + } + #[test] fn test_error_display() { let err = UpdateError::UnsupportedPlatform; From 15e7f69381624d27af6abe4d74dfac1045c8c5c8 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Sun, 26 Jul 2026 19:28:35 +0800 Subject: [PATCH 11/11] ci: centralize platform target matrix --- .github/workflows/ci.yml | 31 +----- .github/workflows/platform-matrix.yml | 153 ++++++++++++++++++++++++++ .github/workflows/release.yml | 127 +-------------------- 3 files changed, 161 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/platform-matrix.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6ee3c4..85939f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,34 +88,9 @@ jobs: # ── Cross-platform check (Linux/macOS/Windows × x64/arm64) ── cross-check: - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-musl - - os: ubuntu-latest - target: aarch64-unknown-linux-musl - - os: macos-latest - target: x86_64-apple-darwin - - os: macos-latest - target: aarch64-apple-darwin - - os: windows-latest - target: x86_64-pc-windows-msvc - - os: windows-11-arm - target: aarch64-pc-windows-msvc - fail-fast: false - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-rust - with: - target: ${{ matrix.target }} - install-protoc: auto - cache-shared-key: cross-check-${{ matrix.target }} - cache-save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Check - run: cargo check --workspace --features "default,plugin-system" --target ${{ matrix.target }} --locked + uses: ./.github/workflows/platform-matrix.yml + with: + operation: check # ── Code coverage ─────────────────────────────────────────────────── coverage: diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml new file mode 100644 index 0000000..85619f8 --- /dev/null +++ b/.github/workflows/platform-matrix.yml @@ -0,0 +1,153 @@ +name: Platform Matrix + +on: + workflow_call: + inputs: + operation: + description: "Run target checks or build release artifacts" + required: true + type: string + version: + description: "Release version applied before artifact builds" + required: false + default: "" + type: string + secrets: + APPLE_DEVELOPER_ID_CERT_BASE64: + required: false + APPLE_DEVELOPER_ID_CERT_PASSWORD: + required: false + APPLE_NOTARY_API_KEY_BASE64: + required: false + APPLE_NOTARY_API_KEY_ID: + required: false + APPLE_NOTARY_API_ISSUER: + required: false + +env: + CARGO_TERM_COLOR: always + +jobs: + targets: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + suffix: "" + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + suffix: "" + - os: macos-latest + target: x86_64-apple-darwin + suffix: "" + macos-deployment-target: "10.15" + - os: macos-latest + target: aarch64-apple-darwin + suffix: "" + macos-deployment-target: "11.0" + - os: windows-latest + target: x86_64-pc-windows-msvc + suffix: ".exe" + - os: windows-11-arm + target: aarch64-pc-windows-msvc + suffix: ".exe" + fail-fast: false + + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/apply-version-bump + if: inputs.operation == 'build' + with: + version: ${{ inputs.version }} + + - uses: ./.github/actions/setup-rust + with: + target: ${{ matrix.target }} + install-protoc: auto + install-zigbuild: ${{ inputs.operation == 'build' && contains(matrix.target, 'musl') }} + cache-shared-key: platform-${{ inputs.operation }}-${{ matrix.target }} + cache-save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Check target + if: inputs.operation == 'check' + run: cargo check --workspace --features "default,plugin-system" --target ${{ matrix.target }} --locked + + - name: Build release binary (musl) + if: inputs.operation == 'build' && contains(matrix.target, 'musl') + run: cargo zigbuild --bin easybot --release --features "default,plugin-system" --target ${{ matrix.target }} --locked + + - name: Build release binary (native) + if: inputs.operation == 'build' && !contains(matrix.target, 'musl') + env: + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos-deployment-target || '' }} + run: cargo build --release --features "default,plugin-system" --target ${{ matrix.target }} --locked + + - name: Check macOS signing credentials + if: inputs.operation == 'build' && contains(matrix.target, 'apple-darwin') + id: check-macos-cert + env: + CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT_BASE64 }} + run: | + if [ -n "$CERT" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "No Apple signing certificate - macOS binary will be unsigned" + fi + + - name: Sign and notarize macOS binary + if: steps.check-macos-cert.outputs.available == 'true' + env: + CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERT_BASE64 }} + CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }} + NOTARY_API_KEY: ${{ secrets.APPLE_NOTARY_API_KEY_BASE64 }} + NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_API_ISSUER }} + run: | + BINARY="target/${{ matrix.target }}/release/easybot${{ matrix.suffix }}" + + echo "$CERT_BASE64" | base64 --decode > certificate.p12 + security create-keychain -p temp temp.keychain + security default-keychain -s temp.keychain + security unlock-keychain -p temp temp.keychain + security import certificate.p12 -k temp.keychain \ + -P "$CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple: -s -k temp temp.keychain + + DEVELOPER_ID=$(security find-identity -v -p basic | + grep "Developer ID Application" | head -1 | sed 's/.*"\(.*\)"/\1/') + codesign --force --sign "$DEVELOPER_ID" \ + --timestamp --options runtime "$BINARY" + + if [ -n "$NOTARY_API_KEY" ]; then + echo "$NOTARY_API_KEY" | base64 --decode > auth_key.p8 + zip -r "$BINARY.zip" "$BINARY" + xcrun notarytool submit "$BINARY.zip" \ + --key auth_key.p8 --key-id "$NOTARY_API_KEY_ID" \ + --issuer "$NOTARY_ISSUER" --wait + xcrun stapler staple "$BINARY" + rm -f "$BINARY.zip" auth_key.p8 + fi + + rm -f certificate.p12 + security delete-keychain temp.keychain + + - name: Prepare artifact + if: inputs.operation == 'build' + shell: bash + run: | + BINARY="target/${{ matrix.target }}/release/easybot${{ matrix.suffix }}" + ARCHIVE="easybot-${{ matrix.target }}${{ matrix.suffix }}" + cp "$BINARY" "$ARCHIVE" + + - name: Upload artifact + if: inputs.operation == 'build' + uses: actions/upload-artifact@v7 + with: + name: easybot-${{ matrix.target }} + path: easybot-${{ matrix.target }}${{ matrix.suffix }} + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fdf697..8104de5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,128 +208,11 @@ jobs: # ── Cross-compile release binaries ───────────────── build-binaries: needs: prepare-release - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-musl - suffix: "" - - os: ubuntu-latest - target: aarch64-unknown-linux-musl - suffix: "" - - os: macos-latest - target: x86_64-apple-darwin - suffix: "" - macos-deployment-target: "10.15" - - os: macos-latest - target: aarch64-apple-darwin - suffix: "" - macos-deployment-target: "11.0" - - os: windows-latest - target: x86_64-pc-windows-msvc - suffix: ".exe" - - os: windows-11-arm - target: aarch64-pc-windows-msvc - suffix: ".exe" - fail-fast: false - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref }} - - - uses: ./.github/actions/apply-version-bump - with: - version: ${{ needs.prepare-release.outputs.version }} - - - uses: ./.github/actions/setup-rust - with: - target: ${{ matrix.target }} - install-zigbuild: ${{ contains(matrix.target, 'musl') }} - - - name: Build release binary (musl — static linking) - if: contains(matrix.target, 'musl') - run: cargo zigbuild --bin easybot --release --features "default,plugin-system" --target ${{ matrix.target }} --locked - - - name: Build release binary (native) - if: ${{ !contains(matrix.target, 'musl') }} - env: - MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos-deployment-target || '' }} - run: cargo build --release --features "default,plugin-system" --target ${{ matrix.target }} --locked - - # ── macOS 签名 + 公证(可选,凭据存在时自动执行) ── - - name: Check macOS signing credentials - if: contains(matrix.target, 'apple-darwin') - id: check-macos-cert - env: - CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT_BASE64 }} - run: | - if [ -n "$CERT" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - echo "No Apple signing certificate — macOS binary will be unsigned" - fi - - - name: Sign and notarize macOS binary - if: steps.check-macos-cert.outputs.available == 'true' - env: - CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERT_BASE64 }} - CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }} - NOTARY_API_KEY: ${{ secrets.APPLE_NOTARY_API_KEY_BASE64 }} - NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} - NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_API_ISSUER }} - run: | - BINARY="target/${{ matrix.target }}/release/easybot${{ matrix.suffix }}" - - # 1. 导入证书到临时 keychain - echo "$CERT_BASE64" | base64 --decode > certificate.p12 - security create-keychain -p temp temp.keychain - security default-keychain -s temp.keychain - security unlock-keychain -p temp temp.keychain - security import certificate.p12 -k temp.keychain \ - -P "$CERT_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list \ - -S apple-tool:,apple: -s -k temp temp.keychain - - # 2. 签名(启用 Hardened Runtime) - DEVELOPER_ID=$(security find-identity -v -p basic | - grep "Developer ID Application" | head -1 | sed 's/.*"\(.*\)"/\1/') - codesign --force --sign "$DEVELOPER_ID" \ - --timestamp --options runtime "$BINARY" - - # 3. 公证 - if [ -n "$NOTARY_API_KEY" ]; then - echo "$NOTARY_API_KEY" | base64 --decode > auth_key.p8 - zip -r "$BINARY.zip" "$BINARY" - xcrun notarytool submit "$BINARY.zip" \ - --key auth_key.p8 --key-id "$NOTARY_API_KEY_ID" \ - --issuer "$NOTARY_ISSUER" --wait - # 4. 贴票 - xcrun stapler staple "$BINARY" - rm -f "$BINARY.zip" auth_key.p8 - fi - - # 清理证书 - rm -f certificate.p12 - security delete-keychain temp.keychain - - - name: Prepare artifact - shell: bash - run: | - BINARY="target/${{ matrix.target }}/release/easybot${{ matrix.suffix }}" - ARCHIVE="easybot-${{ matrix.target }}${{ matrix.suffix }}" - cp "$BINARY" "$ARCHIVE" - echo "sha256=$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" >> $GITHUB_ENV - echo "archive=$ARCHIVE" >> $GITHUB_ENV - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: easybot-${{ matrix.target }} - path: easybot-${{ matrix.target }}${{ matrix.suffix }} - if-no-files-found: error + uses: ./.github/workflows/platform-matrix.yml + with: + operation: build + version: ${{ needs.prepare-release.outputs.version }} + secrets: inherit # ── Create GitHub Release ───────────────────────── create-release: