use std::io::Write;
use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("{0}")]
Usage(String),
#[error(transparent)]
Config(#[from] boatramp_node::config::ConfigError),
#[error("building the {side} blob backend: {source}")]
BackendBuild {
side: &'static str,
#[source]
source: boatramp_node::Error,
},
#[error("resolving the {side} [serve.s3_credential]: {source}")]
Credential {
side: &'static str,
#[source]
source: boatramp_node::s3_credential::S3CredentialError,
},
#[error("building the {side} [secrets] envelope: {reason}")]
Envelope {
side: &'static str,
reason: String,
},
#[error(transparent)]
Migrate(#[from] boatramp_node::blob_migrate::MigrateError),
#[error("blob drain did not complete — see the drain report above (re-run to resume)")]
DrainIncomplete,
#[error("blob purge did not complete — see the purge report above (re-run to resume)")]
PurgeIncomplete,
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(
"no migration SOURCE: pass --from <config>, or configure [serve.blob_fallback] in \
{node_config} (the drain one-liner) — {node_config} has no fallback secondary to drain"
)]
NoSource {
node_config: String,
},
#[error(
"refusing to migrate: source and destination resolve to the SAME backend ({identity}); \
pick distinct --from/--to backends"
)]
SameSourceDest {
identity: String,
},
#[error(transparent)]
KvOpen(#[from] Box<crate::serve::Error>),
#[error("reading the project set from the control-plane KV: {0}")]
KvRead(String),
#[error("blob rehome: {0}")]
Rehome(String),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct BlobArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[command(subcommand)]
command: BlobCommand,
}
#[derive(Debug, Subcommand)]
enum BlobCommand {
Put {
file: std::path::PathBuf,
},
#[cfg(feature = "blob-upload")]
MintUpload(MintUploadArgs),
Migrate(MigrateArgs),
Drain(DrainArgs),
Purge(PurgeArgs),
Status(StatusArgs),
Verify(VerifyArgs),
Rehome(RehomeArgs),
}
#[derive(Debug, clap::Args)]
pub struct RehomeArgs {
#[arg(long, default_value = "boatramp.cfg")]
node_config: std::path::PathBuf,
#[arg(long, value_enum, default_value_t = boatramp_node::backends::KvBackend::Slatedb)]
kv: boatramp_node::backends::KvBackend,
#[arg(long)]
data_dir: Option<std::path::PathBuf>,
#[arg(long)]
finalize: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, clap::Args)]
#[command(group(
clap::ArgGroup::new("purge_mode")
.required(true)
.args(["unreferenced", "drained_source"]),
))]
pub struct PurgeArgs {
#[arg(long)]
unreferenced: bool,
#[arg(long)]
drained_source: bool,
#[arg(long)]
apply: bool,
#[arg(long)]
prefix: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct StatusArgs {}
#[derive(Debug, clap::Args)]
pub struct VerifyArgs {}
#[derive(Debug, clap::Args)]
pub struct DrainArgs {
#[arg(long)]
dry_run: bool,
#[arg(long)]
concurrency: Option<usize>,
#[arg(long)]
prefix: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct MigrateArgs {
#[arg(long)]
from: Option<std::path::PathBuf>,
#[arg(long)]
to: Option<std::path::PathBuf>,
#[arg(long, default_value = "boatramp.cfg")]
node_config: std::path::PathBuf,
#[arg(long, default_value_t = 8)]
concurrency: usize,
#[arg(long)]
no_verify: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, default_value = "")]
prefix: String,
}
#[cfg(feature = "blob-upload")]
#[derive(Debug, clap::Args)]
pub struct MintUploadArgs {
#[arg(long)]
site: String,
#[arg(long, short = 'c')]
container: String,
#[arg(long, conflicts_with = "prefix")]
key: Option<String>,
#[arg(long)]
prefix: Option<String>,
#[arg(long = "perms", value_delimiter = ',')]
perms: Vec<String>,
#[arg(long, default_value_t = 900)]
ttl: u64,
#[arg(long)]
content_type: Option<String>,
#[arg(long)]
max_bytes: Option<u64>,
#[arg(long)]
sha256: bool,
#[arg(long, value_enum, default_value_t = Emit::Human)]
emit: Emit,
}
#[cfg(feature = "blob-upload")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum Emit {
Human,
Env,
Aws,
Rclone,
Json,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, serde::Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum BlobOutput {
Put {
hash: String,
},
#[cfg(feature = "blob-upload")]
MintUpload {
#[serde(serialize_with = "serialize_mint_creds")]
creds: client::MintUploadResult,
#[serde(skip)]
emit: Emit,
},
Migrate {
source: String,
dest: String,
total_objects: u64,
copied_objects: u64,
skipped_objects: u64,
copied_bytes: u64,
verified: bool,
dry_run: bool,
secondary_drained: bool,
},
Drain {
report: serde_json::Value,
},
Purge {
report: serde_json::Value,
},
Status {
state: serde_json::Value,
},
Verify {
state: serde_json::Value,
},
Rehome {
phase: &'static str,
dry_run: bool,
total: u64,
copied: u64,
skipped: u64,
deleted: u64,
blob_retired_marker_set: bool,
},
}
impl crate::output::Rendered for BlobOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Put { hash } => writeln!(w, "{hash}"),
#[cfg(feature = "blob-upload")]
Self::MintUpload { creds, emit } => write_mint(creds, *emit, w),
Self::Migrate {
total_objects,
copied_objects,
skipped_objects,
copied_bytes,
verified,
dry_run,
secondary_drained,
..
} => write_migrate(
*total_objects,
*copied_objects,
*skipped_objects,
*copied_bytes,
*verified,
*dry_run,
*secondary_drained,
w,
),
Self::Drain { report } => write_drain(report, w),
Self::Purge { report } => write_purge(report, w),
Self::Status { state } => write_status(state, w),
Self::Verify { state } => write_verify(state, w),
Self::Rehome {
phase,
dry_run,
total,
copied,
skipped,
deleted,
blob_retired_marker_set,
} => write_rehome(
phase,
*dry_run,
*total,
*copied,
*skipped,
*deleted,
*blob_retired_marker_set,
w,
),
}
}
}
#[cfg(feature = "blob-upload")]
fn serialize_mint_creds<S>(
c: &client::MintUploadResult,
s: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serde::Serialize::serialize(&mint_creds_value(c), s)
}
#[cfg(feature = "blob-upload")]
fn mint_creds_value(c: &client::MintUploadResult) -> serde_json::Value {
serde_json::json!({
"kind": c.kind,
"url": c.url,
"method": c.method,
"required_headers": c.required_headers,
"access_key_id": c.access_key_id,
"secret": c.secret,
"session_token": c.session_token,
"endpoint": c.endpoint,
"region": c.region,
"bucket": c.bucket,
"force_path_style": c.force_path_style,
"expires_at": c.expires_at,
"expires_in_secs": c.expires_in_secs,
"enforced": c.enforced,
"advisory": c.advisory,
})
}
#[allow(clippy::too_many_arguments)]
fn write_migrate(
total_objects: u64,
copied_objects: u64,
skipped_objects: u64,
copied_bytes: u64,
verified: bool,
dry_run: bool,
secondary_drained: bool,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
let verb = if dry_run { "would copy" } else { "copied" };
writeln!(
w,
"blob migrate {}: {} {} object(s), skipped {} present, {} byte(s){}",
if dry_run { "(dry-run)" } else { "complete" },
verb,
copied_objects,
skipped_objects,
copied_bytes,
if verified {
format!("; VERIFY OK: {total_objects} object(s) present in destination")
} else {
String::new()
},
)?;
if secondary_drained {
writeln!(
w,
"SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback and restart the node."
)?;
}
Ok(())
}
fn write_drain(report: &serde_json::Value, w: &mut dyn std::io::Write) -> std::io::Result<()> {
let message = report
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("(no message)");
writeln!(w, "blob drain: {message}")
}
fn write_purge(report: &serde_json::Value, w: &mut dyn std::io::Write) -> std::io::Result<()> {
let message = report
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("(no message)");
writeln!(w, "blob purge: {message}")
}
fn write_status(state: &serde_json::Value, w: &mut dyn std::io::Write) -> std::io::Result<()> {
let active = state
.get("blob_fallback_active")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if active {
writeln!(
w,
"blob status: a read-fallback secondary is ATTACHED ([serve.blob_fallback]) — the \
node is mid-migration (TRANSITION mode). Drain it (blob drain / blob purge \
--drained-source), drop [serve.blob_fallback], and restart to finish."
)
} else {
writeln!(
w,
"blob status: no read-fallback secondary attached — the node is not mid-migration."
)
}
}
fn write_verify(state: &serde_json::Value, w: &mut dyn std::io::Write) -> std::io::Result<()> {
let dead = state
.get("dead_references")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
if dead.is_empty() {
return writeln!(
w,
"blob verify: OK — every referenced blob is present in the backend."
);
}
writeln!(
w,
"blob verify: {} DEAD reference(s) — referenced by a live deployment/function but MISSING \
from the backend (would 404 on invoke/serve):",
dead.len()
)?;
for d in &dead {
let hash = d
.get("hash")
.and_then(serde_json::Value::as_str)
.unwrap_or("?");
let referrer = d
.get("referrer")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
writeln!(w, " • {hash} ({referrer})")?;
}
writeln!(
w,
"\nTo repair: re-upload each missing blob (`boatramp blob put <file>` restores it under the \
same content hash) or re-deploy the resource. (A node on this release no longer creates \
these — GC now references live function components.)"
)
}
#[allow(clippy::too_many_arguments)]
fn write_rehome(
phase: &str,
dry_run: bool,
total: u64,
copied: u64,
skipped: u64,
deleted: u64,
blob_retired_marker_set: bool,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
if phase == "retire" {
writeln!(
w,
"blob rehome (retire{}): {} old key(s) deleted after their new key verified, of {} \
re-homeable object(s). Old bare default-scoped keys reclaimed.{}",
if dry_run { ", dry-run" } else { "" },
deleted,
total,
if blob_retired_marker_set {
" Blob substrate verified re-homed — `boatramp migrate --finalize` may now proceed."
} else {
""
}
)
} else {
writeln!(
w,
"blob rehome (drain{}): {} object(s) copied old→new (verified), {} already present, of \
{} re-homeable object(s). Source kept — run `blob rehome --finalize` after a verified \
soak (with `boatramp migrate --finalize`).",
if dry_run { ", dry-run" } else { "" },
copied,
skipped,
total
)
}
}
pub async fn run(args: BlobArgs, config: &ProjectConfig) -> Result<BlobOutput> {
if let BlobCommand::Migrate(a) = args.command {
return migrate(a).await;
}
if let BlobCommand::Rehome(a) = args.command {
return rehome(a).await;
}
let server = client::resolve_server(args.server, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
let out = match args.command {
BlobCommand::Put { file } => {
let hash = cp.put_file_blob(&file).await?;
BlobOutput::Put { hash }
}
#[cfg(feature = "blob-upload")]
BlobCommand::MintUpload(a) => mint_upload(&cp, a).await?,
BlobCommand::Drain(a) => drain(&cp, a).await?,
BlobCommand::Purge(a) => purge(&cp, a).await?,
BlobCommand::Status(_) => status(&cp).await?,
BlobCommand::Verify(_) => verify(&cp).await?,
BlobCommand::Migrate(_) => unreachable!("handled before the control-plane client"),
BlobCommand::Rehome(_) => unreachable!("handled before the control-plane client"),
};
Ok(out)
}
#[cfg(feature = "fallback")]
async fn rehome(a: RehomeArgs) -> Result<BlobOutput> {
use std::sync::Arc;
let side = build_primary_side("rehome", &a.node_config).await?;
tracing::info!(backend = %side.identity, "blob rehome: backend");
let config = boatramp_node::config::ServerConfig::load(&a.node_config, None)?;
let data_dir = a
.data_dir
.clone()
.or_else(|| config.serve.as_ref().and_then(|s| s.data_dir.clone()))
.unwrap_or_else(|| std::path::PathBuf::from("./data"));
let kv = crate::serve::build_control_plane_kv(
a.kv,
&data_dir,
None,
boatramp_core::kv::KvOpenPolicy::Strict,
None,
)
.await
.map_err(|e| Error::KvOpen(Box::new(crate::serve::Error::from(e))))?;
let mut projects = std::collections::HashSet::new();
for key in kv
.list_prefix(boatramp_core::project::POINTER_PREFIX)
.await
.map_err(|e| Error::KvRead(e.to_string()))?
{
if let Some(name) = key.strip_prefix(boatramp_core::project::POINTER_PREFIX)
&& !name.is_empty()
&& !name.contains('/')
{
projects.insert(name.to_string());
}
}
let projects = Arc::new(projects);
let storage = side.storage.clone(); let opts = boatramp_storage::blob_rehome::RehomeOptions {
prefixes: boatramp_node::blobs::DEFAULT_SCOPE_BLOB_FAMILIES
.iter()
.map(|&p| p.to_string())
.collect(),
to_new: boatramp_node::blobs::default_scope_to_new(projects.clone()),
dry_run: a.dry_run,
};
let (phase, report) = if a.finalize {
(
"retire",
boatramp_storage::blob_rehome::retire(side.storage, &opts)
.await
.map_err(|e| Error::Rehome(e.to_string()))?,
)
} else {
(
"drain",
boatramp_storage::blob_rehome::drain(side.storage, &opts)
.await
.map_err(|e| Error::Rehome(e.to_string()))?,
)
};
let mut blob_marker_set = false;
if a.finalize && !a.dry_run {
let outstanding =
boatramp_node::default_scope::blob_default_outstanding(&storage, projects.as_ref())
.await
.map_err(|e| Error::Rehome(e.to_string()))?;
if outstanding {
return Err(Error::Rehome(
"bare default-scoped blob object(s) still present after retire — run `blob rehome` \
(drain) to copy them forward first, then re-run `--finalize`. The migrate-finalize \
marker was NOT set."
.into(),
));
}
kv.put(
boatramp_node::default_scope::BLOB_RETIRED_MARKER,
b"1".to_vec(),
)
.await
.map_err(|e| Error::KvRead(e.to_string()))?;
kv.flush().await.map_err(|e| Error::KvRead(e.to_string()))?;
blob_marker_set = true;
}
Ok(BlobOutput::Rehome {
phase,
dry_run: a.dry_run,
total: report.total,
copied: report.copied,
skipped: report.skipped,
deleted: report.deleted,
blob_retired_marker_set: blob_marker_set,
})
}
#[cfg(not(feature = "fallback"))]
async fn rehome(_a: RehomeArgs) -> Result<BlobOutput> {
Err(Error::Usage(
"`blob rehome` needs a blob-backend feature (e.g. --features fs/s3/gcs/azure); rebuild \
with one"
.into(),
))
}
async fn drain(cp: &client::ControlPlane, a: DrainArgs) -> Result<BlobOutput> {
let final_report = cp
.blob_drain(a.dry_run, a.concurrency, a.prefix.as_deref())
.await?;
let verify_failed = final_report.get("missing").is_some();
let errored = final_report.get("error").is_some();
if verify_failed || errored {
let mut out = std::io::stdout().lock();
let _ = write_drain(&final_report, &mut out);
let _ = out.flush();
return Err(Error::DrainIncomplete);
}
Ok(BlobOutput::Drain {
report: final_report,
})
}
async fn purge(cp: &client::ControlPlane, a: PurgeArgs) -> Result<BlobOutput> {
let mode = if a.unreferenced {
"unreferenced"
} else {
"drained_source"
};
let final_report = cp.blob_purge(mode, a.apply, a.prefix.as_deref()).await?;
let errored = final_report.get("error").is_some();
if errored {
let mut out = std::io::stdout().lock();
let _ = write_purge(&final_report, &mut out);
let _ = out.flush();
return Err(Error::PurgeIncomplete);
}
Ok(BlobOutput::Purge {
report: final_report,
})
}
async fn status(cp: &client::ControlPlane) -> Result<BlobOutput> {
let state = cp.blob_status().await?;
Ok(BlobOutput::Status { state })
}
async fn verify(cp: &client::ControlPlane) -> Result<BlobOutput> {
let state = cp.blob_verify().await?;
Ok(BlobOutput::Verify { state })
}
async fn migrate(a: MigrateArgs) -> Result<BlobOutput> {
let (source, dest) = resolve_sides(&a).await?;
tracing::info!(source = %source.identity, dest = %dest.identity, "blob migrate: resolved backends");
if source.identity == dest.identity {
return Err(Error::SameSourceDest {
identity: source.identity,
});
}
let is_configured_drain = source.is_configured_fallback && dest.is_configured_primary;
let opts = boatramp_node::blob_migrate::MigrateOptions {
concurrency: a.concurrency,
verify: !a.no_verify,
dry_run: a.dry_run,
prefix: a.prefix.clone(),
on_progress: None,
};
let report = boatramp_node::blob_migrate::migrate(source.storage, dest.storage, &opts).await?;
let drained = report.verified && is_configured_drain;
Ok(BlobOutput::Migrate {
source: source.identity,
dest: dest.identity,
total_objects: report.total_objects,
copied_objects: report.copied_objects,
skipped_objects: report.skipped_objects,
copied_bytes: report.copied_bytes,
verified: report.verified,
dry_run: a.dry_run,
secondary_drained: drained,
})
}
struct ResolvedSide {
storage: std::sync::Arc<dyn boatramp_core::Storage>,
identity: String,
is_configured_fallback: bool,
is_configured_primary: bool,
}
async fn resolve_sides(a: &MigrateArgs) -> Result<(ResolvedSide, ResolvedSide)> {
let source = match a.from.as_ref() {
Some(from) => build_primary_side("source", from).await?,
None => {
let config = boatramp_node::config::ServerConfig::load(&a.node_config, None)?;
let serve = config.serve.clone().unwrap_or_default();
let Some(fb) = serve.blob_fallback.clone() else {
return Err(Error::NoSource {
node_config: a.node_config.display().to_string(),
});
};
build_fallback_side("source", &config, &fb).await?
}
};
let dest_path = a.to.clone().unwrap_or_else(|| a.node_config.clone());
let dest_is_configured_primary = a.to.is_none();
let mut dest = build_primary_side("destination", &dest_path).await?;
dest.is_configured_primary = dest_is_configured_primary;
Ok((source, dest))
}
async fn build_primary_side(
side: &'static str,
config_path: &std::path::Path,
) -> Result<ResolvedSide> {
let config = boatramp_node::config::ServerConfig::load(config_path, None)?;
let serve = config.serve.clone().unwrap_or_default();
let data_dir = serve
.data_dir
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("./data"));
let mut blob_args = boatramp_node::blobs::BlobArgs {
blobs: serve
.blobs
.unwrap_or(boatramp_node::backends::BlobBackend::Fs),
s3_bucket: serve.s3_bucket.clone(),
s3_endpoint: serve.s3_endpoint.clone(),
s3_region: serve.s3_region.clone(),
s3_path_style: serve.s3_path_style,
s3_credential: None,
gcs_bucket: serve.gcs_bucket.clone(),
gcs_endpoint: serve.gcs_endpoint.clone(),
gcs_anonymous: serve.gcs_anonymous,
azure_account: serve.azure_account.clone(),
azure_container: serve.azure_container.clone(),
azure_access_key: serve.azure_access_key.clone(),
azure_emulator: serve.azure_emulator,
};
if let Some(cred_cfg) = serve.s3_credential.clone() {
blob_args.s3_credential =
Some(resolve_side_credential(side, &config, &cred_cfg, &data_dir).await?);
}
let identity = backend_identity(&blob_args, &data_dir);
let built = boatramp_node::blobs::build_blobs(&blob_args, &data_dir, None, None)
.await
.map_err(|source| Error::BackendBuild { side, source })?;
Ok(ResolvedSide {
storage: built.storage,
identity,
is_configured_fallback: false,
is_configured_primary: false,
})
}
async fn build_fallback_side(
side: &'static str,
config: &boatramp_node::config::ServerConfig,
fb: &boatramp_node::config::BlobFallbackConfig,
) -> Result<ResolvedSide> {
let serve = config.serve.clone().unwrap_or_default();
let data_dir = serve
.data_dir
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("./data"));
let mut blob_args = boatramp_node::blobs::BlobArgs {
blobs: fb.blobs.unwrap_or(boatramp_node::backends::BlobBackend::Fs),
s3_bucket: fb.s3_bucket.clone(),
s3_endpoint: fb.s3_endpoint.clone(),
s3_region: fb.s3_region.clone(),
s3_path_style: fb.s3_path_style,
s3_credential: None,
gcs_bucket: fb.gcs_bucket.clone(),
gcs_endpoint: fb.gcs_endpoint.clone(),
gcs_anonymous: fb.gcs_anonymous,
azure_account: fb.azure_account.clone(),
azure_container: fb.azure_container.clone(),
azure_access_key: fb.azure_access_key.clone(),
azure_emulator: fb.azure_emulator,
};
if let Some(cred_cfg) = fb.s3_credential.clone() {
blob_args.s3_credential =
Some(resolve_side_credential(side, config, &cred_cfg, &data_dir).await?);
}
let identity = backend_identity(&blob_args, &data_dir);
let built = boatramp_node::blobs::build_blobs(&blob_args, &data_dir, None, None)
.await
.map_err(|source| Error::BackendBuild { side, source })?;
Ok(ResolvedSide {
storage: built.storage,
identity,
is_configured_fallback: true,
is_configured_primary: false,
})
}
async fn resolve_side_credential(
side: &'static str,
config: &boatramp_node::config::ServerConfig,
cred_cfg: &boatramp_node::config::S3CredentialConfig,
data_dir: &std::path::Path,
) -> Result<boatramp_node::s3_credential::SealedS3Credential> {
use std::sync::Arc;
let posture = config
.security
.clone()
.unwrap_or_default()
.resolve()
.map_err(|e| Error::Envelope {
side,
reason: format!("resolving [security] posture: {e}"),
})?;
let envelope = build_secrets_envelope(side, config.secrets.as_ref(), data_dir)?;
boatramp_node::s3_credential::resolve_s3_credential(
cred_cfg,
Arc::new(boatramp_core::kv::MemoryKv::new()),
envelope,
posture.allow_env_secret_refs,
&boatramp_core::env::SystemEnv,
)
.await
.map_err(|source| Error::Credential { side, source })
}
fn backend_identity(args: &boatramp_node::blobs::BlobArgs, data_dir: &std::path::Path) -> String {
use boatramp_node::backends::BlobBackend;
match args.blobs {
BlobBackend::Fs => format!("fs {}", data_dir.join("blobs").display()),
BlobBackend::S3 => format!(
"s3 bucket={} endpoint={} region={} path_style={}",
args.s3_bucket.as_deref().unwrap_or("?"),
args.s3_endpoint.as_deref().unwrap_or("(default)"),
args.s3_region.as_deref().unwrap_or("(default)"),
args.s3_path_style
),
BlobBackend::Gcs => format!(
"gcs bucket={} endpoint={}",
args.gcs_bucket.as_deref().unwrap_or("?"),
args.gcs_endpoint.as_deref().unwrap_or("(default)")
),
BlobBackend::Azure => format!(
"azure account={} container={}",
args.azure_account.as_deref().unwrap_or("?"),
args.azure_container.as_deref().unwrap_or("?")
),
}
}
fn build_secrets_envelope(
side: &'static str,
secrets: Option<&boatramp_node::config::SecretsConfig>,
data_dir: &std::path::Path,
) -> Result<Option<std::sync::Arc<dyn boatramp_core::envelope::KeyEnvelope>>> {
use boatramp_server::envelope::{EnvelopeSpec, build_envelope};
let Some(cfg) = secrets else {
return Ok(None);
};
let err = |reason: String| Error::Envelope { side, reason };
let spec = match cfg.envelope.as_str() {
"" => EnvelopeSpec::None,
"local" => EnvelopeSpec::Local {
kek_file: cfg
.kek_file
.clone()
.unwrap_or_else(|| data_dir.join("secrets/kek")),
},
"vault" => {
let v = cfg.vault.as_ref().ok_or_else(|| {
err("secrets.envelope = \"vault\" needs a [secrets.vault] section".into())
})?;
let token = std::env::var(&v.token_env)
.map_err(|_| err(format!("Vault token env `{}` is not set", v.token_env)))?;
EnvelopeSpec::Vault {
addr: v.addr.clone(),
key: v.key.clone(),
token,
}
}
other => {
return Err(err(format!(
"unknown secrets.envelope {other:?} (want \"local\" or \"vault\")"
)));
}
};
build_envelope(spec).map_err(|e| err(e.to_string()))
}
#[cfg(feature = "blob-upload")]
async fn mint_upload(cp: &client::ControlPlane, a: MintUploadArgs) -> Result<BlobOutput> {
if a.key.is_none() && a.prefix.is_none() {
return Err(Error::Usage(
"specify exactly one of --key or --prefix".into(),
));
}
let creds = cp
.mint_upload(
&a.site,
&a.container,
a.key.as_deref(),
a.prefix.as_deref(),
&a.perms,
a.ttl,
a.content_type.as_deref(),
a.max_bytes,
a.sha256,
)
.await?;
Ok(BlobOutput::MintUpload {
creds,
emit: a.emit,
})
}
#[cfg(feature = "blob-upload")]
fn write_mint(
c: &client::MintUploadResult,
emit: Emit,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
let is_presigned = c.kind == "presigned_put";
match emit {
Emit::Json => {
writeln!(
w,
"{}",
serde_json::to_string_pretty(&mint_creds_value(c)).unwrap_or_default()
)
}
Emit::Aws => {
if is_presigned {
tracing::warn!(
"# a presigned-put credential is a single URL, not an SDK profile; use --emit env or json"
);
write_presigned(c, w)
} else {
writeln!(w, "[boatramp-upload]")?;
writeln!(w, "aws_access_key_id = {}", opt(&c.access_key_id))?;
writeln!(w, "aws_secret_access_key = {}", opt(&c.secret))?;
writeln!(w, "aws_session_token = {}", opt(&c.session_token))?;
writeln!(w, "# region = {}", opt(&c.region))?;
writeln!(w, "# endpoint_url = {}", opt(&c.endpoint))?;
writeln!(w, "# addressing_style = path")?;
writeln!(
w,
"# bucket = {} (expires in {}s)",
opt(&c.bucket),
c.expires_in_secs
)
}
}
Emit::Rclone => {
if is_presigned {
tracing::warn!(
"# a presigned-put credential is a single URL, not an rclone remote; use --emit env or json"
);
write_presigned(c, w)
} else {
writeln!(w, "[boatramp-upload]")?;
writeln!(w, "type = s3")?;
writeln!(w, "provider = Other")?;
writeln!(w, "access_key_id = {}", opt(&c.access_key_id))?;
writeln!(w, "secret_access_key = {}", opt(&c.secret))?;
writeln!(w, "session_token = {}", opt(&c.session_token))?;
writeln!(w, "region = {}", opt(&c.region))?;
writeln!(w, "endpoint = {}", opt(&c.endpoint))?;
writeln!(w, "force_path_style = true")
}
}
Emit::Env => {
if is_presigned {
write_presigned(c, w)
} else {
write_env(c, w)
}
}
Emit::Human => {
if is_presigned {
writeln!(
w,
"Minted a presigned PUT credential (single-key, browser-friendly):"
)?;
write_presigned(c, w)
} else {
writeln!(
w,
"Minted temporary S3 credentials (path-style; expires in {}s):",
c.expires_in_secs
)?;
writeln!(w, " bucket {}", opt(&c.bucket))?;
writeln!(w, " endpoint {}", opt(&c.endpoint))?;
writeln!(w, " region {}", opt(&c.region))?;
if !c.enforced.is_empty() {
writeln!(w, " enforced {}", c.enforced.join(", "))?;
}
if !c.advisory.is_empty() {
writeln!(w, " advisory {}", c.advisory.join(", "))?;
}
writeln!(w)?;
write_env(c, w)
}
}
}
}
#[cfg(feature = "blob-upload")]
fn write_env(c: &client::MintUploadResult, w: &mut dyn std::io::Write) -> std::io::Result<()> {
writeln!(w, "export AWS_ACCESS_KEY_ID={}", opt(&c.access_key_id))?;
writeln!(w, "export AWS_SECRET_ACCESS_KEY={}", opt(&c.secret))?;
writeln!(w, "export AWS_SESSION_TOKEN={}", opt(&c.session_token))?;
writeln!(w, "export AWS_REGION={}", opt(&c.region))?;
writeln!(w, "export AWS_ENDPOINT_URL={}", opt(&c.endpoint))?;
writeln!(w, "export AWS_S3_FORCE_PATH_STYLE=true")?;
writeln!(w, "# then: aws s3 cp <file> s3://{}/<key>", opt(&c.bucket))
}
#[cfg(feature = "blob-upload")]
fn write_presigned(
c: &client::MintUploadResult,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
let url = opt(&c.url);
writeln!(w, " method {}", opt(&c.method))?;
writeln!(w, " url {url}")?;
for (k, v) in &c.required_headers {
writeln!(w, " header {k}: {v}")?;
}
writeln!(w, " expires in {}s", c.expires_in_secs)?;
let hdrs: String = c
.required_headers
.iter()
.map(|(k, v)| format!(" -H '{k}: {v}'"))
.collect();
writeln!(
w,
"# curl -X {} '{url}'{hdrs} --data-binary @<file>",
opt(&c.method)
)
}
#[cfg(feature = "blob-upload")]
fn opt(v: &Option<String>) -> &str {
v.as_deref().unwrap_or("")
}