a3s-box-sdk 3.1.0

Rust SDK for a3s-box direct runtime-backed management APIs.
Documentation
#[derive(Debug)]
enum LifecycleCall {
    Create {
        request: serde_json::Value,
        operation_id: String,
    },
    Start {
        execution_id: String,
        generation: u64,
    },
    Run {
        request: serde_json::Value,
        operation_id: String,
    },
}

struct RecordingExecutionManager {
    calls: std::sync::Mutex<Vec<LifecycleCall>>,
    reservation: a3s_box_core::ExecutionReservation,
    lease: a3s_box_core::ExecutionLease,
}

#[async_trait::async_trait]
impl a3s_box_core::ExecutionManager for RecordingExecutionManager {
    async fn create(
        &self,
        request: a3s_box_core::CreateExecutionRequest,
        operation_id: &a3s_box_core::OperationId,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ExecutionReservation> {
        self.calls.lock().unwrap().push(LifecycleCall::Create {
            request: serde_json::to_value(request).unwrap(),
            operation_id: operation_id.to_string(),
        });
        Ok(self.reservation.clone())
    }

    async fn start(
        &self,
        execution_id: &a3s_box_core::ExecutionId,
        generation: a3s_box_core::ExecutionGeneration,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ExecutionLease> {
        self.calls.lock().unwrap().push(LifecycleCall::Start {
            execution_id: execution_id.to_string(),
            generation: generation.get(),
        });
        Ok(self.lease.clone())
    }

    async fn create_and_start(
        &self,
        request: a3s_box_core::CreateExecutionRequest,
        operation_id: &a3s_box_core::OperationId,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ExecutionLease> {
        self.calls.lock().unwrap().push(LifecycleCall::Run {
            request: serde_json::to_value(request).unwrap(),
            operation_id: operation_id.to_string(),
        });
        Ok(self.lease.clone())
    }

    async fn inspect(
        &self,
        execution_id: &a3s_box_core::ExecutionId,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ExecutionStatus> {
        Err(a3s_box_core::ExecutionManagerError::NotFound(
            execution_id.clone(),
        ))
    }

    async fn pause(
        &self,
        execution_id: &a3s_box_core::ExecutionId,
        _generation: a3s_box_core::ExecutionGeneration,
        _keep_memory: bool,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ExecutionLease> {
        Err(a3s_box_core::ExecutionManagerError::NotFound(
            execution_id.clone(),
        ))
    }

    async fn resume(
        &self,
        execution_id: &a3s_box_core::ExecutionId,
        _generation: a3s_box_core::ExecutionGeneration,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ExecutionLease> {
        Err(a3s_box_core::ExecutionManagerError::NotFound(
            execution_id.clone(),
        ))
    }

    async fn kill(
        &self,
        execution_id: &a3s_box_core::ExecutionId,
        _generation: a3s_box_core::ExecutionGeneration,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::KillOutcome> {
        Err(a3s_box_core::ExecutionManagerError::NotFound(
            execution_id.clone(),
        ))
    }

    async fn reconcile(
        &self,
        _operation_id: &a3s_box_core::OperationId,
    ) -> a3s_box_core::ExecutionManagerResult<a3s_box_core::ReconcileOutcome> {
        Ok(a3s_box_core::ReconcileOutcome::Absent)
    }
}

#[tokio::test]
async fn lifecycle_calls_preserve_complete_request_and_fencing_identity() {
    use std::collections::BTreeMap;
    use std::sync::Arc;

    use a3s_box_core::{
        resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionGeneration,
        ExecutionHealthCheck, ExecutionId, ExecutionLease, ExecutionRecordPolicy,
        ExecutionReservation, ExecutionRestartPolicy, OperationId, ResourceLimits,
    };
    use chrono::Utc;

    let temp = tempfile::tempdir().unwrap();
    let config = BoxConfig {
        image: "registry.example/sdk:latest".to_string(),
        isolation: a3s_box_core::ExecutionIsolation::Sandbox,
        extra_env: vec![("SDK_CALLER".to_string(), "preserved".to_string())],
        dns: vec!["1.1.1.1".to_string()],
        read_only: true,
        resource_limits: ResourceLimits {
            pids_limit: Some(64),
            ..ResourceLimits::default()
        },
        ..BoxConfig::default()
    };
    let request = CreateExecutionRequest {
        external_sandbox_id: "sdk-external-id".to_string(),
        config: config.clone(),
        labels: BTreeMap::from([("caller".to_string(), "rust-sdk".to_string())]),
        policy: ExecutionRecordPolicy {
            name: Some("sdk-box".to_string()),
            auto_remove: true,
            restart_policy: ExecutionRestartPolicy::OnFailure,
            max_restart_count: 4,
            health_check: Some(ExecutionHealthCheck {
                cmd: vec!["true".to_string()],
                interval_secs: 7,
                timeout_secs: 3,
                retries: 2,
                start_period_secs: 1,
            }),
            healthcheck_disabled: false,
            log_config: a3s_box_core::log::LogConfig::default(),
            volume_names: vec!["sdk-data".to_string()],
            platform: Some("linux/amd64".to_string()),
            init: true,
            devices: vec!["/dev/null".to_string()],
            gpus: Some("none".to_string()),
            shm_size: Some(16 * 1024 * 1024),
            stop_signal: Some("SIGTERM".to_string()),
            stop_timeout: Some(9),
            oom_kill_disable: true,
            oom_score_adj: Some(100),
        },
        rootfs_snapshot_id: None,
    };
    let request_json = serde_json::to_value(&request).unwrap();
    let execution_id = ExecutionId::new("sdk-execution-id").unwrap();
    let operation_id = OperationId::new("sdk-operation-id").unwrap();
    let plan = resolve_execution(&config).unwrap();
    let reservation = ExecutionReservation {
        execution_id: execution_id.clone(),
        generation: ExecutionGeneration::INITIAL,
        plan: plan.clone(),
        resources: config.resources.clone(),
        created_at: Utc::now(),
    };
    let lease = ExecutionLease {
        execution_id: execution_id.clone(),
        generation: ExecutionGeneration::INITIAL,
        plan,
        resources: config.resources,
        started_at: Utc::now(),
    };
    let manager = Arc::new(RecordingExecutionManager {
        calls: std::sync::Mutex::new(Vec::new()),
        reservation,
        lease,
    });
    let client =
        A3sBoxClient::with_execution_manager(A3sBoxPaths::from_home(temp.path()), manager.clone());

    let created = client
        .create_box(request.clone(), &operation_id)
        .await
        .unwrap();
    assert_eq!(created.execution_id, execution_id);
    let started = client
        .start_box(&created.execution_id, created.generation)
        .await
        .unwrap();
    assert_eq!(started.execution_id, execution_id);
    let running = client.run_box(request, &operation_id).await.unwrap();
    assert_eq!(running.execution_id, execution_id);

    let calls = manager.calls.lock().unwrap();
    assert!(matches!(
        &calls[0],
        LifecycleCall::Create {
            request,
            operation_id
        } if request == &request_json && operation_id == "sdk-operation-id"
    ));
    assert!(matches!(
        &calls[1],
        LifecycleCall::Start {
            execution_id,
            generation: 1
        } if execution_id == "sdk-execution-id"
    ));
    assert!(matches!(
        &calls[2],
        LifecycleCall::Run {
            request,
            operation_id
        } if request == &request_json && operation_id == "sdk-operation-id"
    ));
}

#[tokio::test]
async fn sdk_create_rejects_unsupported_microvm_security_before_persistence() {
    let temp = tempfile::tempdir().unwrap();
    let client = A3sBoxClient::from_home(temp.path());

    for (index, (security_option, expected)) in [
        ("apparmor=runtime/default", "AppArmor"),
        ("label=type:container_t", "SELinux"),
        ("seccomp=/profiles/restricted.json", "custom seccomp"),
    ]
    .into_iter()
    .enumerate()
    {
        let request = a3s_box_core::CreateExecutionRequest {
            external_sandbox_id: format!("sdk-unsupported-security-{index}"),
            config: a3s_box_core::BoxConfig {
                image: "alpine:3.20".to_string(),
                isolation: a3s_box_core::ExecutionIsolation::Microvm,
                security_opt: vec![security_option.to_string()],
                ..Default::default()
            },
            labels: Default::default(),
            policy: Default::default(),
            rootfs_snapshot_id: None,
        };
        let operation_id =
            a3s_box_core::OperationId::new(format!("sdk-unsupported-security-operation-{index}"))
                .unwrap();

        let error = client.create_box(request, &operation_id).await.unwrap_err();

        assert!(matches!(
            &error,
            ClientError::Execution(a3s_box_core::ExecutionManagerError::InvalidRequest(message))
                if message.contains(expected)
        ));
        assert!(!client.paths().boxes_file.exists());
    }
}

#[cfg(windows)]
#[tokio::test]
async fn sdk_create_rejects_windows_health_check_before_persistence() {
    let temp = tempfile::tempdir().unwrap();
    let client = A3sBoxClient::from_home(temp.path());
    let mut request = a3s_box_core::CreateExecutionRequest {
        external_sandbox_id: "sdk-windows-health".to_string(),
        config: a3s_box_core::BoxConfig {
            image: "alpine:3.20".to_string(),
            ..Default::default()
        },
        labels: Default::default(),
        policy: Default::default(),
        rootfs_snapshot_id: None,
    };
    request.policy.health_check = Some(a3s_box_core::ExecutionHealthCheck {
        cmd: vec!["true".to_string()],
        interval_secs: 30,
        timeout_secs: 5,
        retries: 3,
        start_period_secs: 0,
    });
    let operation_id = a3s_box_core::OperationId::new("sdk-windows-health-operation").unwrap();

    let error = client.create_box(request, &operation_id).await.unwrap_err();

    assert!(matches!(
        &error,
        ClientError::Execution(a3s_box_core::ExecutionManagerError::InvalidRequest(message))
            if message.contains("health checks are not supported on Windows")
    ));
    assert!(!client.paths().boxes_file.exists());
}

#[cfg(windows)]
#[tokio::test]
async fn sdk_start_rejects_persisted_windows_health_check_without_claiming() {
    let temp = tempfile::tempdir().unwrap();
    let client = A3sBoxClient::from_home(temp.path());
    let mut request = a3s_box_core::CreateExecutionRequest {
        external_sandbox_id: "sdk-persisted-windows-health".to_string(),
        config: a3s_box_core::BoxConfig {
            image: "alpine:3.20".to_string(),
            ..Default::default()
        },
        labels: Default::default(),
        policy: Default::default(),
        rootfs_snapshot_id: None,
    };
    request.policy.health_check = Some(a3s_box_core::ExecutionHealthCheck {
        cmd: vec!["true".to_string()],
        interval_secs: 30,
        timeout_secs: 5,
        retries: 3,
        start_period_secs: 0,
    });
    request.policy.healthcheck_disabled = true;
    let operation_id =
        a3s_box_core::OperationId::new("sdk-persisted-windows-health-operation").unwrap();
    let reservation = client.create_box(request, &operation_id).await.unwrap();

    let state_path = &client.paths().boxes_file;
    let mut state: serde_json::Value =
        serde_json::from_slice(&std::fs::read(state_path).unwrap()).unwrap();
    let record = state.as_array_mut().unwrap().first_mut().unwrap();
    record["healthcheck_disabled"] = serde_json::json!(false);
    record["managed_execution"]["request"]["policy"]["healthcheck_disabled"] =
        serde_json::json!(false);
    std::fs::write(state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap();

    let error = client
        .start_box(&reservation.execution_id, reservation.generation)
        .await
        .unwrap_err();

    assert!(matches!(
        &error,
        ClientError::Execution(a3s_box_core::ExecutionManagerError::InvalidRequest(message))
            if message.contains("health checks are not supported on Windows")
    ));
    let persisted: serde_json::Value =
        serde_json::from_slice(&std::fs::read(state_path).unwrap()).unwrap();
    assert_eq!(persisted[0]["status"], "created");
    assert!(persisted[0]["managed_execution"]["pending_operation"].is_null());
}