feat: add registry os promote command for cross-registry OS version promotion

Batch promotes an entire OS version (metadata + all iso/squashfs/img assets
across all platforms) from one registry to another, mirroring the existing
package promote command.
This commit is contained in:
Aiden McClelland
2026-03-31 22:12:35 -06:00
parent 7c304eef02
commit 208e9a5e3a
5 changed files with 147 additions and 4 deletions

View File

@@ -37,7 +37,7 @@
}, },
"../sdk/dist": { "../sdk/dist": {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.4.0-beta.66", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iarna/toml": "^3.0.0", "@iarna/toml": "^3.0.0",

View File

@@ -1826,6 +1826,21 @@ registry.os.version.signer-not-authorized:
fr_FR: "Le signataire %{signer} n'est pas autorisé à signer pour v%{version}" fr_FR: "Le signataire %{signer} n'est pas autorisé à signer pour v%{version}"
pl_PL: "Sygnatariusz %{signer} nie jest autoryzowany do podpisywania v%{version}" pl_PL: "Sygnatariusz %{signer} nie jest autoryzowany do podpisywania v%{version}"
# registry/os/promote.rs
registry.os.promote.need-from-or-to:
en_US: "At least one of --from or --to must be specified"
de_DE: "Mindestens --from oder --to muss angegeben werden"
es_ES: "Se debe especificar al menos --from o --to"
fr_FR: "Au moins --from ou --to doit être spécifié"
pl_PL: "Należy podać przynajmniej --from lub --to"
registry.os.promote.version-not-found:
en_US: "OS version %{version} not found on source registry"
de_DE: "OS-Version %{version} nicht in der Quell-Registry gefunden"
es_ES: "Versión del SO %{version} no encontrada en el registro de origen"
fr_FR: "Version OS %{version} introuvable dans le registre source"
pl_PL: "Wersja OS %{version} nie znaleziona w rejestrze źródłowym"
# registry/package/mod.rs # registry/package/mod.rs
registry.package.remove-not-exist: registry.package.remove-not-exist:
en_US: "%{id}@%{version}%{sighash} does not exist, so not removed" en_US: "%{id}@%{version}%{sighash} does not exist, so not removed"
@@ -5311,6 +5326,13 @@ about.persist-new-notification:
fr_FR: "Persister une nouvelle notification" fr_FR: "Persister une nouvelle notification"
pl_PL: "Utrwal nowe powiadomienie" pl_PL: "Utrwal nowe powiadomienie"
about.promote-os-registry:
en_US: "Promote an OS version from one registry to another"
de_DE: "Eine OS-Version von einer Registry in eine andere heraufstufen"
es_ES: "Promover una versión del SO de un registro a otro"
fr_FR: "Promouvoir une version OS d'un registre à un autre"
pl_PL: "Promuj wersję OS z jednego rejestru do drugiego"
about.promote-package-registry: about.promote-package-registry:
en_US: "Promote a package from one registry to another" en_US: "Promote a package from one registry to another"
de_DE: "Ein Paket von einer Registry in eine andere heraufstufen" de_DE: "Ein Paket von einer Registry in eine andere heraufstufen"

View File

@@ -8,6 +8,7 @@ pub const SIG_CONTEXT: &str = "startos";
pub mod asset; pub mod asset;
pub mod index; pub mod index;
pub mod promote;
pub mod version; pub mod version;
pub fn os_api<C: Context>() -> ParentHandler<C> { pub fn os_api<C: Context>() -> ParentHandler<C> {
@@ -28,4 +29,10 @@ pub fn os_api<C: Context>() -> ParentHandler<C> {
"version", "version",
version::version_api::<C>().with_about("about.commands-add-remove-list-versions"), version::version_api::<C>().with_about("about.commands-add-remove-list-versions"),
) )
.subcommand(
"promote",
from_fn_async(promote::cli_os_promote)
.no_display()
.with_about("about.promote-os-registry"),
)
} }

View File

@@ -0,0 +1,114 @@
use clap::Parser;
use exver::Version;
use imbl_value::InternedString;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::context::CliContext;
use crate::prelude::*;
use crate::registry::os::SIG_CONTEXT;
use crate::registry::os::index::OsIndex;
use crate::registry::package::promote::{call_registry, resolve_registry_url};
use crate::sign::commitment::blake3::Blake3Commitment;
use crate::sign::ed25519::Ed25519;
use crate::sign::{AnySignature, SignatureScheme};
#[derive(Debug, Deserialize, Serialize, Parser)]
#[group(skip)]
#[command(rename_all = "kebab-case")]
#[serde(rename_all = "camelCase")]
pub struct CliOsPromoteParams {
#[arg(long, help = "help.arg.from-registry-url")]
pub from: Option<Url>,
#[arg(long, help = "help.arg.to-registry-url")]
pub to: Option<Url>,
#[arg(help = "help.arg.os-version")]
pub version: Version,
}
pub async fn cli_os_promote(
ctx: CliContext,
CliOsPromoteParams { from, to, version }: CliOsPromoteParams,
) -> Result<(), Error> {
if from.is_none() && to.is_none() {
return Err(Error::new(
eyre!("{}", t!("registry.os.promote.need-from-or-to")),
ErrorKind::InvalidRequest,
));
}
let from_url = resolve_registry_url(from.as_ref(), &ctx)?;
let to_url = resolve_registry_url(to.as_ref(), &ctx)?;
// Fetch OS index from source registry
let res: Value = call_registry(&ctx, from_url, "os.index", imbl_value::json!({})).await?;
let os_index: OsIndex = from_value(res)?;
// Find the target version
let version_info = os_index
.versions
.0
.get(&version)
.ok_or_else(|| {
Error::new(
eyre!(
"{}",
t!(
"registry.os.promote.version-not-found",
version = &version
)
),
ErrorKind::NotFound,
)
})?;
// Add the version to the target registry
call_registry(
&ctx,
to_url.clone(),
"os.version.add",
imbl_value::json!({
"version": &version,
"headline": &version_info.headline,
"releaseNotes": &version_info.release_notes,
"sourceVersion": &version_info.source_version,
}),
)
.await?;
// Promote all assets for each type and platform
promote_assets(&ctx, &to_url, &version, &version_info.iso, "os.asset.add.iso").await?;
promote_assets(&ctx, &to_url, &version, &version_info.squashfs, "os.asset.add.squashfs").await?;
promote_assets(&ctx, &to_url, &version, &version_info.img, "os.asset.add.img").await?;
Ok(())
}
async fn promote_assets(
ctx: &CliContext,
to_url: &Url,
version: &Version,
assets: &std::collections::BTreeMap<InternedString, crate::registry::asset::RegistryAsset<Blake3Commitment>>,
method: &str,
) -> Result<(), Error> {
for (platform, asset) in assets {
let commitment = &asset.commitment;
let signature =
AnySignature::Ed25519(Ed25519.sign_commitment(ctx.developer_key()?, commitment, SIG_CONTEXT)?);
call_registry(
ctx,
to_url.clone(),
method,
imbl_value::json!({
"version": version,
"platform": platform,
"url": &asset.urls[0],
"signature": signature,
"commitment": commitment,
}),
)
.await?;
}
Ok(())
}

View File

@@ -28,7 +28,7 @@ pub struct CliPromoteParams {
pub version: VersionString, pub version: VersionString,
} }
fn registry_rpc_url(url: &Url) -> Result<Url, Error> { pub fn registry_rpc_url(url: &Url) -> Result<Url, Error> {
let mut url = url.clone(); let mut url = url.clone();
url.path_segments_mut() url.path_segments_mut()
.map_err(|_| eyre!("Url cannot be base")) .map_err(|_| eyre!("Url cannot be base"))
@@ -38,7 +38,7 @@ fn registry_rpc_url(url: &Url) -> Result<Url, Error> {
Ok(url) Ok(url)
} }
fn resolve_registry_url(explicit: Option<&Url>, ctx: &CliContext) -> Result<Url, Error> { pub fn resolve_registry_url(explicit: Option<&Url>, ctx: &CliContext) -> Result<Url, Error> {
if let Some(url) = explicit { if let Some(url) = explicit {
registry_rpc_url(url) registry_rpc_url(url)
} else if let Some(url) = &ctx.registry_url { } else if let Some(url) = &ctx.registry_url {
@@ -51,7 +51,7 @@ fn resolve_registry_url(explicit: Option<&Url>, ctx: &CliContext) -> Result<Url,
} }
} }
async fn call_registry( pub async fn call_registry(
ctx: &CliContext, ctx: &CliContext,
url: Url, url: Url,
method: &str, method: &str,