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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **SBOM extension scopes carry a stable image id.** (ENG-2199)
`ext:<runtime>/<name>` scope elements now carry `externalIdentifier`/
`verifiedUsing` from the runtime's build manifest, and `rootfs`/
`initramfs` carry `os_build_id`/`initramfs_build_id` once `runtime
var-image` has run — so a device-reported image can be joined back onto
the scope that describes it. `spdxId`s and the namespace are unchanged.
- **`avocado sbom --device [user@]host[:port]` describes a running
device.** (ENG-2199) Reads the device's active runtime and merged
extensions over SSH and filters the build SBOM to that set. Anything
merged that the build doesn't cover is listed as an uncovered element
instead of dropped, and a runtime/OS build id that disagrees with the
device's is warned about rather than failed on.

## [1.0.0-rc.5] - 2026-09-17

### Changed
Expand Down
22 changes: 16 additions & 6 deletions src/commands/connect/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::commands::connect::client::{
ConnectClient, ContainerDiscoveryResult, CreateRuntimeRequest, HttpStatus, RuntimeParams,
UploadPartError,
};
use crate::commands::sbom::generate::{in_runtime, runtime_has_packages, SbomCommand};
use crate::commands::sbom::generate::{in_runtime, runtime_has_packages, ImageIds, SbomCommand};
use crate::utils::config::{load_config, Config};
use crate::utils::container::{RunConfig, SdkContainer};
use crate::utils::output::{
Expand Down Expand Up @@ -308,7 +308,7 @@ impl ConnectUploadCommand {
// Phase B: Create runtime via API. The SBOM (ENG-2219) is built in
// this phase rather than one of its own.
let (runtime, num_artifacts) = run_phase(PHASE_CREATE, async {
let sbom = self.build_sbom().await;
let sbom = self.build_sbom(manifest).await;
self.create_runtime_api(
connect,
version,
Expand Down Expand Up @@ -464,11 +464,14 @@ impl ConnectUploadCommand {
/// contract `read_config_and_lockfile` already has for the lockfile.
///
/// `AVOCADO_UPLOAD_NO_SBOM=1` skips the build outright.
async fn build_sbom(&self) -> Option<serde_json::Value> {
///
/// `manifest` is discovery's manifest.json, passed through so extension
/// scopes carry the `image_id`s this upload is about to publish.
async fn build_sbom(&self, manifest: &serde_json::Value) -> Option<serde_json::Value> {
if std::env::var("AVOCADO_UPLOAD_NO_SBOM").as_deref() == Ok("1") {
return None;
}
match self.scan_runtime_sbom().await {
match self.scan_runtime_sbom(manifest).await {
Ok(doc) => Some(doc),
Err(e) => {
// Not `print_warning`: that one is suppressed under
Expand All @@ -483,7 +486,7 @@ impl ConnectUploadCommand {
}

/// `avocado sbom`'s document, filtered to this runtime by `in_runtime`.
async fn scan_runtime_sbom(&self) -> Result<serde_json::Value> {
async fn scan_runtime_sbom(&self, manifest: &serde_json::Value) -> Result<serde_json::Value> {
let cmd = SbomCommand::new(
self.config_path.clone(),
self.target.clone(),
Expand All @@ -510,7 +513,14 @@ impl ConnectUploadCommand {
self.runtime
);
}
Ok(cmd.build_document(&kept, &target, snapshot.as_ref(), Some(&self.runtime)))
let images = ImageIds::from_manifest(manifest, &self.runtime);
Ok(cmd.build_document(
&kept,
&target,
snapshot.as_ref(),
Some(&self.runtime),
Some(&images),
))
}

/// Handle the case where the runtime is already in draft status (full dedup).
Expand Down
134 changes: 1 addition & 133 deletions src/commands/runtime/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::commands::connect::client::{self as connect_client, ConnectClient};
use crate::utils::{
config::{ComposedConfig, Config},
container::{is_docker_desktop, is_vm_routing_active, RunConfig, SdkContainer},
device::DeviceSpec,
lockfile::LockFile,
output::{print_info, print_success, print_warning, OutputLevel},
output_format::{emit_json_event, is_json_output_active},
Expand All @@ -16,64 +17,6 @@ use std::sync::Arc;
const DEFAULT_DEPLOY_REPO_PORT: u16 = 8585;
const DEPLOY_STAGING_DIR: &str = ".avocado/deploy-staging";

/// Parsed representation of a device connection string.
///
/// Accepts formats: `host`, `user@host`, `host:port`, `user@host:port`
#[derive(Debug, Clone, PartialEq)]
struct DeviceSpec {
user: String,
host: String,
port: Option<u16>,
}

impl DeviceSpec {
fn parse(device: &str) -> Result<Self> {
let (user, host_port) = if let Some(at_pos) = device.find('@') {
let user = &device[..at_pos];
anyhow::ensure!(!user.is_empty(), "Empty user in device string '{device}'");
(user.to_string(), &device[at_pos + 1..])
} else {
("root".to_string(), device)
};

anyhow::ensure!(
!host_port.is_empty(),
"Empty host in device string '{device}'"
);

let (host, port) = if let Some(colon_pos) = host_port.rfind(':') {
let maybe_port = &host_port[colon_pos + 1..];
match maybe_port.parse::<u16>() {
Ok(p) => {
let h = &host_port[..colon_pos];
anyhow::ensure!(!h.is_empty(), "Empty host in device string '{device}'");
(h.to_string(), Some(p))
}
// Not a valid port number -- treat the whole thing as a hostname
// (e.g. IPv6 addresses like ::1)
Err(_) => (host_port.to_string(), None),
}
} else {
(host_port.to_string(), None)
};

Ok(Self { user, host, port })
}

/// SSH destination in `user@host` form.
fn ssh_destination(&self) -> String {
format!("{}@{}", self.user, self.host)
}

/// SSH port arguments: `["-p", "<port>"]` if a port was specified, empty otherwise.
fn ssh_port_args(&self) -> String {
match self.port {
Some(p) => format!("-p {p}"),
None => String::new(),
}
}
}

pub struct RuntimeDeployCommand {
runtime_name: String,
config_path: String,
Expand Down Expand Up @@ -1269,81 +1212,6 @@ mod tests {
);
}

// --- DeviceSpec parsing tests ---

#[test]
fn test_device_spec_bare_host() {
let spec = DeviceSpec::parse("192.168.1.100").unwrap();
assert_eq!(spec.user, "root");
assert_eq!(spec.host, "192.168.1.100");
assert_eq!(spec.port, None);
assert_eq!(spec.ssh_destination(), "root@192.168.1.100");
assert_eq!(spec.ssh_port_args(), "");
}

#[test]
fn test_device_spec_user_at_host() {
let spec = DeviceSpec::parse("admin@10.0.0.1").unwrap();
assert_eq!(spec.user, "admin");
assert_eq!(spec.host, "10.0.0.1");
assert_eq!(spec.port, None);
assert_eq!(spec.ssh_destination(), "admin@10.0.0.1");
}

#[test]
fn test_device_spec_host_with_port() {
let spec = DeviceSpec::parse("127.0.0.1:2222").unwrap();
assert_eq!(spec.user, "root");
assert_eq!(spec.host, "127.0.0.1");
assert_eq!(spec.port, Some(2222));
assert_eq!(spec.ssh_destination(), "root@127.0.0.1");
assert_eq!(spec.ssh_port_args(), "-p 2222");
}

#[test]
fn test_device_spec_user_host_port() {
let spec = DeviceSpec::parse("root@127.0.0.1:2222").unwrap();
assert_eq!(spec.user, "root");
assert_eq!(spec.host, "127.0.0.1");
assert_eq!(spec.port, Some(2222));
assert_eq!(spec.ssh_destination(), "root@127.0.0.1");
assert_eq!(spec.ssh_port_args(), "-p 2222");
}

#[test]
fn test_device_spec_hostname_no_port() {
let spec = DeviceSpec::parse("device.local").unwrap();
assert_eq!(spec.user, "root");
assert_eq!(spec.host, "device.local");
assert_eq!(spec.port, None);
}

#[test]
fn test_device_spec_hostname_with_port() {
let spec = DeviceSpec::parse("device.local:22").unwrap();
assert_eq!(spec.user, "root");
assert_eq!(spec.host, "device.local");
assert_eq!(spec.port, Some(22));
}

#[test]
fn test_device_spec_fqdn_user_port() {
let spec = DeviceSpec::parse("deploy@edge.company.com:2200").unwrap();
assert_eq!(spec.user, "deploy");
assert_eq!(spec.host, "edge.company.com");
assert_eq!(spec.port, Some(2200));
}

#[test]
fn test_device_spec_empty_fails() {
assert!(DeviceSpec::parse("").is_err());
}

#[test]
fn test_device_spec_empty_user_fails() {
assert!(DeviceSpec::parse("@host").is_err());
}

// --- RuntimeDeployCommand tests ---

#[test]
Expand Down
Loading
Loading