feat(upgrade): add self-update command from Gitea Release
Some checks failed
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
- Add secrets upgrade command: --check to verify, default to download and replace binary - No database or master key required - Support tar.gz and zip artifacts from Gitea Release Made-with: Cursor
This commit is contained in:
@@ -9,6 +9,7 @@ pub async fn run(action: crate::ConfigAction) -> Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
|
||||
drop(pool);
|
||||
println!("Database connection successful.");
|
||||
|
||||
let cfg = Config {
|
||||
database_url: Some(url.clone()),
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod rollback;
|
||||
pub mod run;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
pub mod upgrade;
|
||||
|
||||
198
src/commands/upgrade.rs
Normal file
198
src/commands/upgrade.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::Deserialize;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest";
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Detect the asset suffix for the current platform/arch at compile time.
|
||||
fn platform_asset_suffix() -> Result<&'static str> {
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
Ok("x86_64-linux-musl.tar.gz")
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
Ok("aarch64-macos.tar.gz")
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
Ok("x86_64-macos.tar.gz")
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
Ok("x86_64-windows.zip")
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
)))]
|
||||
bail!(
|
||||
"Unsupported platform: {}/{}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
)
|
||||
}
|
||||
|
||||
/// Strip the "secrets-" prefix from the tag and parse as semver.
|
||||
fn parse_tag_version(tag: &str) -> Result<semver::Version> {
|
||||
let ver_str = tag
|
||||
.strip_prefix("secrets-")
|
||||
.with_context(|| format!("unexpected tag format: {tag}"))?;
|
||||
semver::Version::parse(ver_str)
|
||||
.with_context(|| format!("failed to parse version from tag: {tag}"))
|
||||
}
|
||||
|
||||
/// Extract the binary from a tar.gz archive (first file whose name == "secrets").
|
||||
fn extract_from_targz(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let gz = GzDecoder::new(Cursor::new(bytes));
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
for entry in archive.entries().context("failed to read tar entries")? {
|
||||
let mut entry = entry.context("bad tar entry")?;
|
||||
let path = entry.path().context("bad tar entry path")?.into_owned();
|
||||
let fname = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
if fname == "secrets" || fname == "secrets.exe" {
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf).context("read tar entry")?;
|
||||
return Ok(buf);
|
||||
}
|
||||
}
|
||||
bail!("binary not found inside tar.gz archive")
|
||||
}
|
||||
|
||||
/// Extract the binary from a zip archive (first file whose name matches).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn extract_from_zip(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let reader = Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(reader).context("failed to open zip archive")?;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).context("bad zip entry")?;
|
||||
let fname = file.name().to_owned();
|
||||
if fname.ends_with("secrets.exe") || fname.ends_with("secrets") {
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).context("read zip entry")?;
|
||||
return Ok(buf);
|
||||
}
|
||||
}
|
||||
bail!("binary not found inside zip archive")
|
||||
}
|
||||
|
||||
pub async fn run(check_only: bool) -> Result<()> {
|
||||
let current = semver::Version::parse(CURRENT_VERSION).context("invalid current version")?;
|
||||
|
||||
println!("Current version: v{current}");
|
||||
println!("Checking for updates...");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(format!("secrets-cli/{CURRENT_VERSION}"))
|
||||
.build()
|
||||
.context("failed to build HTTP client")?;
|
||||
|
||||
let release: Release = client
|
||||
.get(GITEA_API)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to fetch release info from Gitea")?
|
||||
.error_for_status()
|
||||
.context("Gitea API returned an error")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse release JSON")?;
|
||||
|
||||
let latest = parse_tag_version(&release.tag_name)?;
|
||||
|
||||
if latest <= current {
|
||||
println!("Already up to date (v{current})");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("New version available: v{latest}");
|
||||
|
||||
if check_only {
|
||||
println!("Run `secrets upgrade` to update.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = platform_asset_suffix()?;
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(suffix))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no asset found for this platform (looking for suffix: {suffix})\navailable: {}",
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("Downloading {}...", asset.name);
|
||||
|
||||
let bytes = client
|
||||
.get(&asset.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("download failed")?
|
||||
.error_for_status()
|
||||
.context("download returned an error")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read download body")?;
|
||||
|
||||
println!("Extracting...");
|
||||
|
||||
let binary = if suffix.ends_with(".tar.gz") {
|
||||
extract_from_targz(&bytes)?
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
extract_from_zip(&bytes)?
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
bail!("zip extraction is only supported on Windows")
|
||||
};
|
||||
|
||||
// Write to a temporary file, set executable permission, then atomically replace.
|
||||
let mut tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?;
|
||||
tmp.write_all(&binary)
|
||||
.context("failed to write temp binary")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o755);
|
||||
std::fs::set_permissions(tmp.path(), perms).context("failed to chmod temp binary")?;
|
||||
}
|
||||
|
||||
self_replace::self_replace(tmp.path()).context("failed to replace current binary")?;
|
||||
|
||||
println!("Updated: v{current} → v{latest}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,7 +6,7 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
tracing::debug!("connecting to database");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
tracing::debug!("database connection established");
|
||||
|
||||
23
src/main.rs
23
src/main.rs
@@ -388,6 +388,22 @@ EXAMPLES:
|
||||
#[arg(last = true, required = true)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
|
||||
/// Check for a newer version and update the binary in-place.
|
||||
///
|
||||
/// Downloads the latest release from Gitea and replaces the current binary.
|
||||
/// No database connection or master key required.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Check for updates only (no download)
|
||||
secrets upgrade --check
|
||||
|
||||
# Download and install the latest version
|
||||
secrets upgrade")]
|
||||
Upgrade {
|
||||
/// Only check if a newer version is available; do not download
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -422,6 +438,11 @@ async fn main() -> Result<()> {
|
||||
return commands::config::run(action).await;
|
||||
}
|
||||
|
||||
// upgrade needs no database or master key either
|
||||
if let Commands::Upgrade { check } = cli.command {
|
||||
return commands::upgrade::run(check).await;
|
||||
}
|
||||
|
||||
let db_url = config::resolve_db_url(&cli.db_url)?;
|
||||
let pool = db::create_pool(&db_url).await?;
|
||||
db::migrate(&pool).await?;
|
||||
@@ -435,7 +456,7 @@ async fn main() -> Result<()> {
|
||||
// except delete which operates on plaintext metadata only.
|
||||
|
||||
match cli.command {
|
||||
Commands::Init | Commands::Config { .. } => unreachable!(),
|
||||
Commands::Init | Commands::Config { .. } | Commands::Upgrade { .. } => unreachable!(),
|
||||
|
||||
Commands::Add {
|
||||
namespace,
|
||||
|
||||
Reference in New Issue
Block a user