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
61 changes: 56 additions & 5 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3066,6 +3066,23 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
"failure": observation.failure.as_ref().map(endpoint_failure_to_json),
})
});
let runtime = sandbox
.status
.as_ref()
.and_then(|status| status.runtime.as_ref())
.map(|runtime| {
serde_json::json!({
"supervisor": runtime.supervisor.as_ref().map(|build| serde_json::json!({
"version": build.version,
"build_commit": build.build_commit,
})),
"workload_image": runtime.workload_image.as_ref().map(|image| serde_json::json!({
"reference": image.reference,
"digest": image.digest,
"digest_kind": image.digest_kind,
})),
})
});
let admission = sandbox
.status
.as_ref()
Expand Down Expand Up @@ -3123,6 +3140,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
"conditions": conditions,
"endpoint_statuses": endpoint_statuses,
"last_token_grant_failure": last_token_grant_failure,
"runtime": runtime,
"configuration_admission": admission,
"provisioning": provisioning,
"created_from_workload_template": created_from_workload_template,
Expand Down Expand Up @@ -7219,11 +7237,11 @@ mod tests {
use openshell_core::proto::{
EndpointResult, EndpointStatus, GetSandboxConfigResponse, GpuResourceRequirements,
PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy, SandboxSpec,
SandboxStatus, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole,
datamodel::v1::ObjectMeta,
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy,
SandboxRuntime, SandboxSpec, SandboxStatus, SandboxSupervisorBuild, SandboxWorkloadConfig,
SandboxWorkloadImage, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance,
SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceEndpoint,
ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta,
};

#[test]
Expand Down Expand Up @@ -8893,6 +8911,39 @@ mod tests {
);
}

#[test]
fn sandbox_json_reports_runtime_evidence_without_guessing_missing_values() {
let sandbox = Sandbox {
status: Some(SandboxStatus {
runtime: Some(SandboxRuntime {
supervisor: Some(SandboxSupervisorBuild {
version: "0.1.6".into(),
build_commit: None,
}),
workload_image: Some(SandboxWorkloadImage {
reference: "example/agent:latest".into(),
digest: "sha256:pinned".into(),
digest_kind: "image_id".into(),
}),
}),
..Default::default()
}),
..Default::default()
};
assert_eq!(
super::sandbox_to_json(&sandbox)["runtime"],
serde_json::json!({
"supervisor": {"version": "0.1.6", "build_commit": null},
"workload_image": {
"reference": "example/agent:latest",
"digest": "sha256:pinned",
"digest_kind": "image_id"
}
})
);
assert!(super::sandbox_to_json(&Sandbox::default())["runtime"].is_null());
}

#[test]
fn sandbox_detail_to_json_sandbox_source_without_policy() {
let sandbox = Sandbox {
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-driver-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ non-root UID, primary GID, and supplementary-group set. Docker launches
for every canonical and exec process. UID or GID zero and unresolved symbolic
identities are rejected.

Driver status reports the workload container's image ID to the gateway. The
public `Sandbox.status.runtime.workload_image` combines that observed ID with
the effective template reference and labels its digest kind `image_id`.

An absolute OCI working directory becomes the workspace. An empty, root (`/`),
or explicit `/sandbox` declaration uses `/sandbox`. Any other workdir must
already exist without symlink components. The resolved identity must be able to
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6589,6 +6589,7 @@ fn driver_status_from_summary(
transition_time: None,
}],
deleting,
workload_image_id: summary.image_id.clone().unwrap_or_default(),
..Default::default()
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/openshell-driver-docker/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3729,6 +3729,7 @@ fn build_container_create_body_uses_runtime_namespace_label() {
fn driver_status_keeps_running_sandboxes_provisioning_with_stable_message() {
let running = ContainerSummary {
id: Some("cid".to_string()),
image_id: Some("sha256:first-image".to_string()),
names: Some(vec!["/openshell-demo".to_string()]),
labels: Some(HashMap::from([
(LABEL_SANDBOX_ID.to_string(), "sbx-1".to_string()),
Expand Down Expand Up @@ -3757,6 +3758,16 @@ fn driver_status_keeps_running_sandboxes_provisioning_with_stable_message() {
assert_eq!(running_status.conditions[0].reason, "BackendReady");
assert_eq!(running_status.conditions[0].message, "Container is running");
assert_eq!(running_status.conditions, running_later_status.conditions);
assert_eq!(running_status.workload_image_id, "sha256:first-image");

let moved_tag = ContainerSummary {
image_id: Some("sha256:second-image".to_string()),
..running
};
assert_eq!(
driver_status_from_summary(&moved_tag, "demo").workload_image_id,
"sha256:second-image"
);

let exited_status = driver_status_from_summary(&exited, "demo");
assert_eq!(exited_status.conditions[0].status, "False");
Expand Down
5 changes: 5 additions & 0 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ supplementary groups. Root and unresolved identities fail before provisioning.
Images must not prepopulate the reserved `/.openshell` hierarchy; this prevents
image-controlled symlinks from aliasing private control state into user mounts.

An inspected workload container reports its pinned image ID in driver status.
The gateway exposes it as `Sandbox.status.runtime.workload_image`, with digest
kind `image_id`. A list observation without an image ID retains an earlier ID
only for the same container and effective image reference.

`sandbox_runtime_image` supplies the statically linked musl
`/openshell-sandbox` binary. Podman's read-only image volume delivers it to the
workload; user-namespace modes that cannot use image volumes retain the trusted
Expand Down
9 changes: 3 additions & 6 deletions crates/openshell-driver-podman/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ pub struct ContainerInspect {
#[serde(default)]
pub mounts: Option<Vec<Value>>,
pub id: String,
/// Immutable image ID reported by Podman's top-level Image field.
#[serde(default)]
pub image: String,
pub name: String,
pub state: ContainerState,
#[serde(default)]
Expand Down Expand Up @@ -172,12 +175,6 @@ pub struct ContainerConfig {
pub labels: HashMap<String, String>,
#[serde(default)]
pub user: String,
/// The immutable image ID the container was created from. Podman records
/// the reference used at creation time here; workloads are always created
/// from a resolved image ID, so this pins the image the container runs
/// independently of any later tag movement.
#[serde(default)]
pub image: String,
}

/// Immutable image metadata needed to bind OCI identity inspection to launch.
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-driver-podman/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,7 +1640,7 @@ impl PodmanComputeDriver {
.inspect_container(existing_workload_container_id)
.await
.map_err(ComputeDriverError::from)?;
let pinned_image = workload.config.image;
let pinned_image = workload.image;
if pinned_image.is_empty() {
return Err(ComputeDriverError::Precondition(
"retained Podman workload does not record its pinned image ID".into(),
Expand Down Expand Up @@ -2777,7 +2777,7 @@ mod tests {
// created from an immutable image ID.
StubResponse::new(
StatusCode::OK,
r#"{"Id":"ctr-retained","Name":"workload","State":{"Status":"exited","Running":false},"Config":{"Image":"sha256:pinned-image-id"}}"#,
r#"{"Id":"ctr-retained","Name":"workload","Image":"sha256:pinned-image-id","State":{"Status":"exited","Running":false},"Config":{"Image":"app:test"}}"#,
),
// Inspection of the pinned image by ID, not by its tag.
StubResponse::new(
Expand Down
44 changes: 42 additions & 2 deletions crates/openshell-driver-podman/src/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,15 +480,19 @@ pub fn driver_sandbox_from_inspect(inspect: &ContainerInspect) -> Option<DriverS
let condition = condition_from_state(&inspect.state);
let deleting = inspect.state.status == "removing";

Some(build_driver_sandbox(
let mut sandbox = build_driver_sandbox(
sandbox_id,
sandbox_name,
workspace,
inspect.name.trim_start_matches('/').to_string(),
short_id(&inspect.id),
condition,
deleting,
))
);
if let Some(status) = sandbox.status.as_mut() {
status.workload_image_id.clone_from(&inspect.image);
}
Some(sandbox)
}

/// Build a `DriverSandbox` from a container list entry (no inspect needed).
Expand Down Expand Up @@ -990,6 +994,42 @@ mod tests {
assert_eq!(cond.status, "True");
assert_eq!(cond.reason, "ContainerRunning");
assert!(!status.deleting);
assert!(status.workload_image_id.is_empty());
}

#[test]
fn inspected_workload_reports_the_pinned_image_id() {
let inspect: ContainerInspect = serde_json::from_value(serde_json::json!({
"Id": "container-1",
"Name": "openshell-demo",
"Image": "sha256:pinned-image",
"State": {"Status": "running", "Running": true, "ExitCode": 0},
"Config": {
"Labels": {
"openshell.ai/sandbox-id": "sandbox-1",
"openshell.ai/sandbox-name": "demo",
"openshell.ai/sandbox-workspace": "default"
},
"Image": "example/agent:latest"
}
}))
.unwrap();
let sandbox = driver_sandbox_from_inspect(&inspect).unwrap();
assert_eq!(
sandbox.status.unwrap().workload_image_id,
"sha256:pinned-image"
);

let mut without_id = inspect;
without_id.image.clear();
assert!(
driver_sandbox_from_inspect(&without_id)
.unwrap()
.status
.unwrap()
.workload_image_id
.is_empty()
);
}

#[test]
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ The server owns control-plane RPCs, authentication, provider credential delivery
and sandbox lifecycle orchestration. The `openshell-gateway` crate composes the
server with selected compute drivers.

`Sandbox.status.runtime` joins two existing observation paths: an image ID in
the compute driver's status and a build version in a dedicated supervisor's
authenticated session hello. The gateway retains the last accepted supervisor
version across driver snapshots. An image ID survives a lightweight driver
snapshot only while the container instance and effective image reference match.
Missing evidence remains absent; the VM's `resolved_identity.resource_digest`
is a rootfs cache identity, so it is not presented as an image digest.

## Sandbox launch identity

A new start, including an automatic restart, commits a fresh runtime generation
Expand Down
81 changes: 80 additions & 1 deletion crates/openshell-server/src/compute/image_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ impl ComputeRuntime {
let recover = async {
ensure_image_update_stopped(&sandbox)?;
self.ensure_configuration_update_supported()?;
self.complete_image_update(sandbox, intent.clone()).await?;
Box::pin(self.complete_image_update(sandbox, intent.clone())).await?;
Ok(())
};
let result = tokio::time::timeout_at(deadline, recover)
Expand Down Expand Up @@ -1770,6 +1770,85 @@ BEGIN SELECT RAISE(ABORT, 'injected sandbox deletion failure'); END",
);
}

#[tokio::test]
async fn image_change_clears_old_id_before_status_reconciliation() {
let (runtime, driver, mut original) = fixture(true).await;
original.status.as_mut().unwrap().runtime = Some(openshell_core::proto::SandboxRuntime {
workload_image: Some(openshell_core::proto::SandboxWorkloadImage {
reference: "example/workload:old".into(),
digest: "sha256:old".into(),
digest_kind: "image_id".into(),
}),
..Default::default()
});
runtime.store.put_message(&original).await.unwrap();
let original = runtime
.store
.get_message::<Sandbox>(original.object_id())
.await
.unwrap()
.unwrap();

driver.set_image_update_error(Some(Code::Unavailable));
runtime
.update_sandbox_image(
original.object_id(),
"example/workload:new".into(),
original.get_resource_version(),
)
.await
.unwrap_err();
let mut pending = runtime
.store
.get_message::<Sandbox>(original.object_id())
.await
.unwrap()
.unwrap();
assert_eq!(
pending
.spec
.as_ref()
.unwrap()
.template
.as_ref()
.unwrap()
.image,
"example/workload:new"
);
assert!(
pending
.status
.as_ref()
.unwrap()
.runtime
.as_ref()
.and_then(|runtime| runtime.workload_image.as_ref())
.is_none()
);

let incoming = DriverSandbox {
id: original.object_id().into(),
name: original.object_name().into(),
status: Some(DriverSandboxStatus {
instance_id: "old-container".into(),
workload_image_id: "sha256:old".into(),
..Default::default()
}),
..Default::default()
};
apply_driver_snapshot(&mut pending, &incoming, false, false);
assert!(
pending
.status
.as_ref()
.unwrap()
.runtime
.as_ref()
.and_then(|runtime| runtime.workload_image.as_ref())
.is_none()
);
}

#[tokio::test]
async fn image_update_preserves_identity_and_every_other_spec_field() {
let (runtime, driver, mut original) = fixture(true).await;
Expand Down
Loading
Loading