mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-20 06:00:06 +08:00
Model CPU, memory, and GPU through one typed ResourceRequirements envelope across inline sandbox specs, reusable workload templates, and compute drivers. Update the CLI, gateway translation, and built-in drivers to consume the unified model. BREAKING CHANGE: CPU and memory move to typed resource requirements, and SandboxWorkloadConfig.resources now uses ResourceRequirements instead of SandboxResources. Part of #2838 Signed-off-by: Evan Lezar <elezar@nvidia.com>
847 lines
34 KiB
Rust
847 lines
34 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Gateway-private, versioned protobuf formats for durable storage.
|
|
|
|
#![allow(
|
|
clippy::all,
|
|
clippy::pedantic,
|
|
clippy::nursery,
|
|
dead_code,
|
|
unused_imports,
|
|
unused_qualifications,
|
|
rust_2018_idioms
|
|
)]
|
|
|
|
include!(concat!(env!("OUT_DIR"), "/openshell.storage.v1.rs"));
|
|
|
|
pub(crate) const STORAGE_FILE_DESCRIPTOR_SET: &[u8] =
|
|
include_bytes!(concat!(env!("OUT_DIR"), "/storage_descriptor.bin"));
|
|
|
|
use openshell_core::{
|
|
GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
impl ObjectId for StoredProviderProfile {
|
|
fn object_id(&self) -> &str {
|
|
self.metadata.as_ref().map_or("", |m| m.id.as_str())
|
|
}
|
|
}
|
|
|
|
impl ObjectName for StoredProviderProfile {
|
|
fn object_name(&self) -> &str {
|
|
self.metadata.as_ref().map_or("", |m| m.name.as_str())
|
|
}
|
|
}
|
|
|
|
impl ObjectLabels for StoredProviderProfile {
|
|
fn object_labels(&self) -> Option<HashMap<String, String>> {
|
|
self.metadata.as_ref().map(|m| m.labels.clone())
|
|
}
|
|
}
|
|
|
|
impl SetResourceVersion for StoredProviderProfile {
|
|
fn set_resource_version(&mut self, version: u64) {
|
|
if let Some(meta) = self.metadata.as_mut() {
|
|
meta.resource_version = version;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl GetResourceVersion for StoredProviderProfile {
|
|
fn get_resource_version(&self) -> u64 {
|
|
self.metadata.as_ref().map_or(0, |m| m.resource_version)
|
|
}
|
|
}
|
|
|
|
impl ObjectWorkspace for StoredProviderProfile {
|
|
fn object_workspace(&self) -> &str {
|
|
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
|
|
}
|
|
|
|
fn requires_workspace() -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
impl ObjectId for StoredProviderCredentialRefreshStateV2 {
|
|
fn object_id(&self) -> &str {
|
|
self.metadata.as_ref().map_or("", |m| m.id.as_str())
|
|
}
|
|
}
|
|
|
|
impl ObjectName for StoredProviderCredentialRefreshStateV2 {
|
|
fn object_name(&self) -> &str {
|
|
self.metadata.as_ref().map_or("", |m| m.name.as_str())
|
|
}
|
|
}
|
|
|
|
impl ObjectLabels for StoredProviderCredentialRefreshStateV2 {
|
|
fn object_labels(&self) -> Option<HashMap<String, String>> {
|
|
self.metadata.as_ref().map(|m| m.labels.clone())
|
|
}
|
|
}
|
|
|
|
impl SetResourceVersion for StoredProviderCredentialRefreshStateV2 {
|
|
fn set_resource_version(&mut self, version: u64) {
|
|
if let Some(meta) = self.metadata.as_mut() {
|
|
meta.resource_version = version;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl GetResourceVersion for StoredProviderCredentialRefreshStateV2 {
|
|
fn get_resource_version(&self) -> u64 {
|
|
self.metadata.as_ref().map_or(0, |m| m.resource_version)
|
|
}
|
|
}
|
|
|
|
impl ObjectWorkspace for StoredProviderCredentialRefreshStateV2 {
|
|
fn object_workspace(&self) -> &str {
|
|
self.metadata.as_ref().map_or("", |m| m.workspace.as_str())
|
|
}
|
|
|
|
fn requires_workspace() -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use prost::Message;
|
|
use prost_types::{DescriptorProto, EnumDescriptorProto, FileDescriptorSet};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
|
|
const STORAGE_V1_SCHEMA_SHA256: &str =
|
|
"d68401809d8cea445c35233ef32412bbd041cb2ac5acaf368a0d0bf74d2ddf17";
|
|
const PUBLIC_RPC_SCHEMA_SHA256: &str =
|
|
"ee8fe0b408ad9fac8b5c41052cf450aee63c820ec8538662f583807c9cdab104";
|
|
const DURABLE_SCHEMA_SHA256: &str =
|
|
"068016428c90b13cb6ebfce386c377f5c09273e9bbab90a68bbd07b81bc01166";
|
|
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
|
|
"8215dbe0a6173a9a128e0c97200ce496da25990454dac8bfc1eecb6ce7b92000";
|
|
// A persisted Sandbox without endpoint status retains its lifecycle fields;
|
|
// the absent repeated field decodes empty and needs no database rewrite.
|
|
const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964";
|
|
// Synthetic payloads generated with the public declarations at v0.0.116,
|
|
// before their relocation into openshell.storage.v1. Values are deliberately
|
|
// non-secret and the ordinary protobuf bytes contain no package names.
|
|
const V0_0_116_REFRESH_STATE: &str = "0a230a096c65676163792d6964120b6c65676163792d6e616d6528073a0764656661756c74120b70726f76696465722d69641a0870726f76696465722205544f4b454e32160a09636c69656e745f6964120973796e7468657469633a0d726566726573685f746f6b656e406448c8015a06616374697665720773636f70652d619a011f0a0d726566726573685f746f6b656e120e0a047465737412066f7061717565a201150a036f6c64120e0a047465737412066f7061717565";
|
|
const V0_0_116_DELETION: &str = "0a036f6c64120e0a047465737412066f7061717565";
|
|
const V0_0_116_PROFILE: &str = "0a230a096c65676163792d6964120b6c65676163792d6e616d6528073a0764656661756c7412110a0770726f66696c6512064c6567616379";
|
|
const V0_0_116_POLICY_PAYLOAD: &str =
|
|
"0a0012067368613235361a046e6f6e6520ac022a110a06736f75726365120766697874757265";
|
|
const V0_0_116_DRAFT_PAYLOAD: &str =
|
|
"0a0472756c651a07666978747572652d0000403f3a0b6578616d706c652e636f6d40bb035002";
|
|
const V0_0_116_POLICY_RECORD: &str = "0a09706f6c6963792d6964120a73616e64626f782d6964180222030102032a0673686132353632066c6f616465643a046e6f6e6540fa0148ac0252110a06736f75726365120766697874757265";
|
|
const V0_0_116_DRAFT_RECORD: &str = "0a086368756e6b2d6964120a73616e64626f782d69641802220770656e64696e672a0472756c65320204053a076669787475726549000000000000e83f50de02589003620b6578616d706c652e636f6d68bb037801";
|
|
const STORAGE_MESSAGE_NAMES: [&str; 9] = [
|
|
"DraftChunkPayload",
|
|
"PolicyRevisionPayload",
|
|
"StoredConfigUpdateOperation",
|
|
"StoredDraftChunk",
|
|
"StoredPolicyRevision",
|
|
"StoredProviderCredentialRefreshState",
|
|
"StoredProviderCredentialRefreshStateV2",
|
|
"StoredProviderProfile",
|
|
"StoredRefreshMaterialDeletion",
|
|
];
|
|
const PROVIDER_READINESS_RPC_SIGNATURES: [&str; 2] = [
|
|
"openshell.v1.OpenShell/GetSandboxProviderStatus|.openshell.v1.GetSandboxProviderStatusRequest|.openshell.v1.GetSandboxProviderStatusResponse|false|false",
|
|
"openshell.v1.OpenShell/ReportProviderReadiness|.openshell.v1.ReportProviderReadinessRequest|.openshell.v1.ReportProviderReadinessResponse|false|false",
|
|
];
|
|
// Synthetic SandboxSpec bytes with log level, provider, and command fields,
|
|
// emitted before the gateway-owned attachment epoch field was introduced.
|
|
const PRE_READINESS_SANDBOX_SPEC: &str =
|
|
"0a04696e666f421273796e7468657469632d70726f766964657262046563686f";
|
|
const DURABLE_ROOTS: &[&str] = &[
|
|
".openshell.datamodel.v1.Provider",
|
|
".openshell.datamodel.v1.Workspace",
|
|
".openshell.sandbox.v1.SandboxPolicy",
|
|
".openshell.storage.v1.DraftChunkPayload",
|
|
".openshell.storage.v1.PolicyRevisionPayload",
|
|
".openshell.storage.v1.StoredConfigUpdateOperation",
|
|
".openshell.storage.v1.StoredProviderCredentialRefreshStateV2",
|
|
".openshell.storage.v1.StoredProviderProfile",
|
|
".openshell.v1.Sandbox",
|
|
".openshell.v1.SandboxWorkloadTemplate",
|
|
".openshell.v1.ServiceEndpoint",
|
|
".openshell.v1.SshSession",
|
|
".openshell.v1.WorkspaceMember",
|
|
];
|
|
|
|
#[derive(Default)]
|
|
struct SchemaIndex<'a> {
|
|
messages: BTreeMap<String, &'a DescriptorProto>,
|
|
enums: BTreeMap<String, &'a EnumDescriptorProto>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SchemaClosure {
|
|
messages: BTreeSet<String>,
|
|
enums: BTreeSet<String>,
|
|
}
|
|
|
|
fn qualified_name(prefix: &str, name: &str) -> String {
|
|
if prefix.is_empty() {
|
|
format!(".{name}")
|
|
} else {
|
|
format!("{prefix}.{name}")
|
|
}
|
|
}
|
|
|
|
fn index_message<'a>(index: &mut SchemaIndex<'a>, prefix: &str, message: &'a DescriptorProto) {
|
|
let name = qualified_name(prefix, message.name.as_deref().expect("message name"));
|
|
index.messages.insert(name.clone(), message);
|
|
for nested in &message.nested_type {
|
|
index_message(index, &name, nested);
|
|
}
|
|
for nested_enum in &message.enum_type {
|
|
index.enums.insert(
|
|
qualified_name(&name, nested_enum.name.as_deref().expect("enum name")),
|
|
nested_enum,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn index_descriptor<'a>(index: &mut SchemaIndex<'a>, descriptor: &'a FileDescriptorSet) {
|
|
for file in &descriptor.file {
|
|
let package = file.package.as_deref().unwrap_or_default();
|
|
let prefix = if package.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(".{package}")
|
|
};
|
|
for message in &file.message_type {
|
|
index_message(index, &prefix, message);
|
|
}
|
|
for r#enum in &file.enum_type {
|
|
index.enums.insert(
|
|
qualified_name(&prefix, r#enum.name.as_deref().expect("enum name")),
|
|
r#enum,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn schema_closure(
|
|
index: &SchemaIndex<'_>,
|
|
roots: impl IntoIterator<Item = String>,
|
|
) -> SchemaClosure {
|
|
let mut messages = BTreeSet::new();
|
|
let mut enums = BTreeSet::new();
|
|
let mut pending = roots.into_iter().collect::<VecDeque<_>>();
|
|
while let Some(name) = pending.pop_front() {
|
|
if let Some(message) = index.messages.get(&name) {
|
|
if !messages.insert(name) {
|
|
continue;
|
|
}
|
|
for field in &message.field {
|
|
let Some(type_name) = field.type_name.as_ref() else {
|
|
continue;
|
|
};
|
|
if index.messages.contains_key(type_name) {
|
|
pending.push_back(type_name.clone());
|
|
} else if index.enums.contains_key(type_name) {
|
|
enums.insert(type_name.clone());
|
|
}
|
|
}
|
|
} else if index.enums.contains_key(&name) {
|
|
enums.insert(name);
|
|
} else {
|
|
panic!("schema root or dependency {name} is missing from descriptors");
|
|
}
|
|
}
|
|
SchemaClosure { messages, enums }
|
|
}
|
|
|
|
fn append_one_message_schema(output: &mut String, full_name: &str, message: &DescriptorProto) {
|
|
output.push_str(&format!(
|
|
"message|{}|{}|{:?}|{:?}|{:?}\n",
|
|
full_name,
|
|
message
|
|
.options
|
|
.as_ref()
|
|
.map(|options| hex::encode(options.encode_to_vec()))
|
|
.unwrap_or_default(),
|
|
message.reserved_range,
|
|
message.reserved_name,
|
|
message.oneof_decl,
|
|
));
|
|
for field in &message.field {
|
|
output.push_str(&format!(
|
|
"field|{}|{}|{}|{}|{}|{}|{}|{}|{}\n",
|
|
full_name,
|
|
field.number.unwrap_or_default(),
|
|
field.name.as_deref().unwrap_or_default(),
|
|
field.label.unwrap_or_default(),
|
|
field.r#type.unwrap_or_default(),
|
|
field.type_name.as_deref().unwrap_or_default(),
|
|
field.oneof_index.unwrap_or(-1),
|
|
field.proto3_optional.unwrap_or(false),
|
|
field
|
|
.options
|
|
.as_ref()
|
|
.map(|options| hex::encode(options.encode_to_vec()))
|
|
.unwrap_or_default(),
|
|
));
|
|
}
|
|
}
|
|
|
|
fn schema_fingerprint(index: &SchemaIndex<'_>, closure: &SchemaClosure) -> String {
|
|
let mut schema = String::new();
|
|
for name in &closure.messages {
|
|
append_one_message_schema(&mut schema, name, index.messages[name]);
|
|
}
|
|
for name in &closure.enums {
|
|
let r#enum = index.enums[name];
|
|
schema.push_str(&format!(
|
|
"enum|{}|{}|{:?}|{:?}\n",
|
|
name,
|
|
r#enum
|
|
.options
|
|
.as_ref()
|
|
.map(|options| hex::encode(options.encode_to_vec()))
|
|
.unwrap_or_default(),
|
|
r#enum.reserved_range,
|
|
r#enum.reserved_name,
|
|
));
|
|
for value in &r#enum.value {
|
|
schema.push_str(&format!(
|
|
"enum-value|{}|{}|{}|{}\n",
|
|
name,
|
|
value.number.unwrap_or_default(),
|
|
value.name.as_deref().unwrap_or_default(),
|
|
value
|
|
.options
|
|
.as_ref()
|
|
.map(|options| hex::encode(options.encode_to_vec()))
|
|
.unwrap_or_default(),
|
|
));
|
|
}
|
|
}
|
|
format!("{:x}", Sha256::digest(schema.as_bytes()))
|
|
}
|
|
|
|
fn append_message_schema(output: &mut String, prefix: &str, message: &DescriptorProto) {
|
|
let name = message.name.as_deref().expect("message name");
|
|
let full_name = if prefix.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{prefix}.{name}")
|
|
};
|
|
output.push_str(&format!(
|
|
"message|{}|{}|{:?}|{:?}\n",
|
|
full_name,
|
|
message
|
|
.options
|
|
.as_ref()
|
|
.map(|options| hex::encode(options.encode_to_vec()))
|
|
.unwrap_or_default(),
|
|
message.reserved_range,
|
|
message.reserved_name,
|
|
));
|
|
|
|
for field in &message.field {
|
|
output.push_str(&format!(
|
|
"field|{}|{}|{}|{}|{}|{}|{}|{}|{}\n",
|
|
full_name,
|
|
field.number.unwrap_or_default(),
|
|
field.name.as_deref().unwrap_or_default(),
|
|
field.label.unwrap_or_default(),
|
|
field.r#type.unwrap_or_default(),
|
|
field.type_name.as_deref().unwrap_or_default(),
|
|
field.oneof_index.unwrap_or(-1),
|
|
field.proto3_optional.unwrap_or(false),
|
|
field
|
|
.options
|
|
.as_ref()
|
|
.map(|options| hex::encode(options.encode_to_vec()))
|
|
.unwrap_or_default(),
|
|
));
|
|
}
|
|
for nested in &message.nested_type {
|
|
append_message_schema(output, &full_name, nested);
|
|
}
|
|
}
|
|
|
|
fn storage_schema() -> (Vec<String>, String) {
|
|
let descriptor = FileDescriptorSet::decode(STORAGE_FILE_DESCRIPTOR_SET)
|
|
.expect("storage descriptor set must decode");
|
|
let file = descriptor
|
|
.file
|
|
.iter()
|
|
.find(|file| file.package.as_deref() == Some("openshell.storage.v1"))
|
|
.expect("storage descriptor must contain openshell.storage.v1");
|
|
let mut names = file
|
|
.message_type
|
|
.iter()
|
|
.map(|message| message.name.clone().expect("message name"))
|
|
.collect::<Vec<_>>();
|
|
names.sort();
|
|
|
|
let mut messages = file.message_type.iter().collect::<Vec<_>>();
|
|
messages.sort_by_key(|message| message.name.as_deref().unwrap_or_default());
|
|
let mut schema = String::new();
|
|
for message in messages {
|
|
append_message_schema(&mut schema, "", message);
|
|
}
|
|
(names, schema)
|
|
}
|
|
|
|
#[test]
|
|
fn storage_v1_schema_is_frozen() {
|
|
let (names, schema) = storage_schema();
|
|
assert_eq!(names, STORAGE_MESSAGE_NAMES);
|
|
let actual = format!("{:x}", Sha256::digest(schema.as_bytes()));
|
|
assert_eq!(
|
|
actual, STORAGE_V1_SCHEMA_SHA256,
|
|
"openshell.storage.v1 changed; keep v1 decoders intact and introduce a versioned migration path before updating this reviewed fingerprint"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn storage_types_are_absent_from_public_descriptor() {
|
|
let public = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET)
|
|
.expect("public descriptor set must decode");
|
|
for file in public.file {
|
|
assert_ne!(file.package.as_deref(), Some("openshell.storage.v1"));
|
|
for message in file.message_type {
|
|
let name = message.name.expect("message name");
|
|
assert!(
|
|
!STORAGE_MESSAGE_NAMES.contains(&name.as_str()),
|
|
"storage-only message {name} leaked into the public descriptor"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_responses_reserve_legacy_booleans() {
|
|
let public = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
|
|
for (name, legacy) in [
|
|
("DeleteSandboxTemplateResponse", "deleted"),
|
|
("DeleteSandboxResponse", "deleted"),
|
|
("DeleteServiceResponse", "deleted"),
|
|
("DeleteProviderResponse", "deleted"),
|
|
("DeleteProviderRefreshResponse", "deleted"),
|
|
("DeleteProviderProfileResponse", "deleted"),
|
|
("DeleteWorkspaceResponse", "deleted"),
|
|
("RemoveWorkspaceMemberResponse", "removed"),
|
|
("RevokeSshSessionResponse", "revoked"),
|
|
] {
|
|
let message = public
|
|
.file
|
|
.iter()
|
|
.filter(|file| file.package.as_deref() == Some("openshell.v1"))
|
|
.flat_map(|file| &file.message_type)
|
|
.find(|message| message.name.as_deref() == Some(name))
|
|
.unwrap();
|
|
assert!(message.reserved_name.iter().any(|name| name == legacy));
|
|
assert!(
|
|
message
|
|
.reserved_range
|
|
.iter()
|
|
.any(|range| range.start == Some(1) && range.end == Some(2))
|
|
);
|
|
let outcome = message
|
|
.field
|
|
.iter()
|
|
.find(|field| field.name.as_deref() == Some("outcome"))
|
|
.unwrap();
|
|
assert_eq!(outcome.number, Some(2));
|
|
assert_eq!(
|
|
outcome.type_name.as_deref(),
|
|
Some(".openshell.v1.DeletionOutcome")
|
|
);
|
|
}
|
|
let legacy = openshell_core::proto::DeleteSandboxResponse::decode(&[8, 1][..]).unwrap();
|
|
assert_eq!(legacy.outcome, 0);
|
|
let unknown = openshell_core::proto::DeleteSandboxResponse::decode(&[16, 99][..]).unwrap();
|
|
assert_eq!(unknown.outcome, 99);
|
|
}
|
|
|
|
#[test]
|
|
fn public_and_durable_schema_inventories_are_complete() {
|
|
let public = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET)
|
|
.expect("public descriptor set must decode");
|
|
let storage = FileDescriptorSet::decode(STORAGE_FILE_DESCRIPTOR_SET)
|
|
.expect("storage descriptor set must decode");
|
|
let mut index = SchemaIndex::default();
|
|
index_descriptor(&mut index, &storage);
|
|
index_descriptor(&mut index, &public);
|
|
|
|
let mut methods = Vec::new();
|
|
let mut public_roots = BTreeSet::new();
|
|
let mut compiled_method_count = 0;
|
|
for file in &public.file {
|
|
let package = file.package.as_deref().unwrap_or_default();
|
|
for service in &file.service {
|
|
let service_name = service.name.as_deref().expect("service name");
|
|
compiled_method_count += service.method.len();
|
|
if !matches!(
|
|
(package, service_name),
|
|
("openshell.v1", "OpenShell") | ("openshell.inference.v1", "Inference")
|
|
) {
|
|
continue;
|
|
}
|
|
for method in &service.method {
|
|
let input = method.input_type.as_deref().expect("method input type");
|
|
let output = method.output_type.as_deref().expect("method output type");
|
|
public_roots.insert(input.to_string());
|
|
public_roots.insert(output.to_string());
|
|
methods.push(format!(
|
|
"{package}.{service_name}/{}|{}|{}|{}|{}",
|
|
method.name.as_deref().expect("method name"),
|
|
input,
|
|
output,
|
|
method.client_streaming.unwrap_or(false),
|
|
method.server_streaming.unwrap_or(false),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
methods.sort();
|
|
for signature in PROVIDER_READINESS_RPC_SIGNATURES {
|
|
assert!(
|
|
methods.iter().any(|method| method == signature),
|
|
"provider readiness RPC is missing or changed: {signature}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
compiled_method_count,
|
|
102 + PROVIDER_READINESS_RPC_SIGNATURES.len(),
|
|
"classify every compiled RPC"
|
|
);
|
|
assert_eq!(
|
|
methods.len(),
|
|
76 + PROVIDER_READINESS_RPC_SIGNATURES.len(),
|
|
"inventory every public gateway RPC"
|
|
);
|
|
assert_eq!(
|
|
methods
|
|
.iter()
|
|
.filter(|method| method.starts_with("openshell.v1.OpenShell/"))
|
|
.count(),
|
|
76 + PROVIDER_READINESS_RPC_SIGNATURES.len()
|
|
);
|
|
assert!(methods.iter().all(|method| !method.contains(".storage.")));
|
|
|
|
let public_closure = schema_closure(&index, public_roots);
|
|
let durable_closure =
|
|
schema_closure(&index, DURABLE_ROOTS.iter().copied().map(str::to_string));
|
|
let overlap_messages = public_closure
|
|
.messages
|
|
.intersection(&durable_closure.messages)
|
|
.cloned()
|
|
.collect::<BTreeSet<_>>();
|
|
let overlap_enums = public_closure
|
|
.enums
|
|
.intersection(&durable_closure.enums)
|
|
.cloned()
|
|
.collect::<BTreeSet<_>>();
|
|
let overlap_inventory = overlap_messages
|
|
.iter()
|
|
.map(|name| format!("message|{name}"))
|
|
.chain(overlap_enums.iter().map(|name| format!("enum|{name}")))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let public_schema_hash = schema_fingerprint(&index, &public_closure);
|
|
let public_inventory_hash = format!(
|
|
"{:x}",
|
|
Sha256::digest(format!("{}\n{public_schema_hash}", methods.join("\n")).as_bytes())
|
|
);
|
|
let durable_inventory_hash = schema_fingerprint(&index, &durable_closure);
|
|
let overlap_hash = format!("{:x}", Sha256::digest(overlap_inventory.as_bytes()));
|
|
|
|
// Report the complete measured inventory on failure so one schema
|
|
// change exposes every affected boundary in the same focused run.
|
|
assert_eq!(
|
|
(
|
|
(public_closure.messages.len(), public_closure.enums.len()),
|
|
(durable_closure.messages.len(), durable_closure.enums.len()),
|
|
(overlap_messages.len(), overlap_enums.len()),
|
|
public_inventory_hash.as_str(),
|
|
durable_inventory_hash.as_str(),
|
|
overlap_hash.as_str(),
|
|
),
|
|
(
|
|
(301, 24),
|
|
(94, 19),
|
|
(82, 19),
|
|
PUBLIC_RPC_SCHEMA_SHA256,
|
|
DURABLE_SCHEMA_SHA256,
|
|
PUBLIC_DURABLE_OVERLAP_SHA256
|
|
),
|
|
"the public/durable schema inventory changed; review API and storage ownership, preserve prior-payload decoding, and update the reviewed fingerprints"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_readiness_sandbox_spec_decodes_with_initial_attachment_epoch() {
|
|
let spec = openshell_core::proto::SandboxSpec::decode(
|
|
legacy_bytes(PRE_READINESS_SANDBOX_SPEC).as_slice(),
|
|
)
|
|
.expect("prior sandbox spec must decode");
|
|
assert_eq!(spec.log_level, "info");
|
|
assert_eq!(spec.providers, ["synthetic-provider"]);
|
|
assert_eq!(spec.command, ["echo"]);
|
|
assert!(spec.provider_attachment_epoch.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_admission_sandbox_bytes_preserve_legacy_status() {
|
|
use openshell_core::proto::{Sandbox, SandboxPhase};
|
|
// Synthetic Sandbox encoded with main 0357daee, before admission fields.
|
|
let bytes =
|
|
legacy_bytes("0a180a096c65676163792d6964120b6c65676163792d6e616d651a0430023807");
|
|
let sandbox = Sandbox::decode(bytes.as_slice()).unwrap();
|
|
let status = sandbox.status.as_ref().unwrap();
|
|
assert_eq!(status.phase, SandboxPhase::Ready as i32);
|
|
assert_eq!(status.current_policy_version, 7);
|
|
assert!(status.configuration_admission.is_none());
|
|
assert_eq!(status.configuration_activated, None);
|
|
assert!(status.provisioning.is_none());
|
|
assert!(!crate::policy_store::permits_initial_static_policy_repair(
|
|
&sandbox
|
|
));
|
|
assert_eq!(sandbox.encode_to_vec(), bytes);
|
|
}
|
|
|
|
fn legacy_bytes(encoded: &str) -> Vec<u8> {
|
|
hex::decode(encoded).expect("checked-in legacy fixture must be valid hex")
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_payload_without_endpoint_status_decodes() {
|
|
use openshell_core::proto::{Sandbox, SandboxPhase};
|
|
|
|
let sandbox = Sandbox::decode(legacy_bytes(SANDBOX_WITHOUT_ENDPOINT_STATUS).as_slice())
|
|
.expect("stored sandbox without endpoint status must decode");
|
|
let metadata = sandbox.metadata.expect("sandbox metadata");
|
|
assert_eq!(metadata.id, "sandbox-id");
|
|
assert_eq!(metadata.name, "sandbox");
|
|
assert_eq!(metadata.workspace, "default");
|
|
let status = sandbox.status.expect("sandbox status");
|
|
assert_eq!(status.sandbox_name, "sandbox");
|
|
assert_eq!(status.phase(), SandboxPhase::Ready);
|
|
assert_eq!(status.current_policy_version, 7);
|
|
assert_eq!(status.main_process_instance_id, "supervisor-id");
|
|
assert_eq!(status.exit_code, None);
|
|
assert_eq!(status.conditions.len(), 1);
|
|
assert_eq!(status.conditions[0].r#type, "Ready");
|
|
assert_eq!(status.conditions[0].status, "True");
|
|
assert!(status.endpoint_statuses.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_move_storage_payloads_decode_after_package_relocation() {
|
|
let refresh = StoredProviderCredentialRefreshState::decode(
|
|
legacy_bytes(V0_0_116_REFRESH_STATE).as_slice(),
|
|
)
|
|
.expect("legacy refresh state must decode");
|
|
assert_eq!(refresh.provider_id, "provider-id");
|
|
assert_eq!(refresh.provider_name, "provider");
|
|
assert_eq!(refresh.credential_key, "TOKEN");
|
|
assert_eq!(refresh.material["client_id"], "synthetic");
|
|
assert_eq!(refresh.secret_material_keys, ["refresh_token"]);
|
|
assert_eq!(refresh.expires_at_ms, 100);
|
|
assert_eq!(refresh.next_refresh_at_ms, 200);
|
|
assert_eq!(refresh.status, "active");
|
|
assert_eq!(refresh.scopes, ["scope-a"]);
|
|
assert_eq!(
|
|
refresh.secret_material_handles["refresh_token"].driver,
|
|
"test"
|
|
);
|
|
assert_eq!(refresh.pending_secret_deletions[0].material_key, "old");
|
|
|
|
let deletion =
|
|
StoredRefreshMaterialDeletion::decode(legacy_bytes(V0_0_116_DELETION).as_slice())
|
|
.expect("legacy deletion must decode");
|
|
assert_eq!(deletion.material_key, "old");
|
|
assert_eq!(deletion.handle.expect("handle").driver, "test");
|
|
|
|
let profile = StoredProviderProfile::decode(legacy_bytes(V0_0_116_PROFILE).as_slice())
|
|
.expect("legacy provider profile must decode");
|
|
let profile = profile.profile.expect("profile");
|
|
assert_eq!(profile.id, "profile");
|
|
assert_eq!(profile.display_name, "Legacy");
|
|
|
|
let policy_payload =
|
|
PolicyRevisionPayload::decode(legacy_bytes(V0_0_116_POLICY_PAYLOAD).as_slice())
|
|
.expect("legacy policy payload must decode");
|
|
assert!(policy_payload.policy.is_some());
|
|
assert_eq!(policy_payload.hash, "sha256");
|
|
assert_eq!(policy_payload.load_error, "none");
|
|
assert_eq!(policy_payload.loaded_at_ms, 300);
|
|
assert_eq!(policy_payload.provenance["source"], "fixture");
|
|
|
|
let draft_payload =
|
|
DraftChunkPayload::decode(legacy_bytes(V0_0_116_DRAFT_PAYLOAD).as_slice())
|
|
.expect("legacy draft payload must decode");
|
|
assert_eq!(draft_payload.rule_name, "rule");
|
|
assert_eq!(draft_payload.rationale, "fixture");
|
|
assert_eq!(draft_payload.confidence, 0.75);
|
|
assert_eq!(draft_payload.host, "example.com");
|
|
assert_eq!(draft_payload.port, 443);
|
|
assert_eq!(draft_payload.draft_version, 2);
|
|
|
|
let policy = StoredPolicyRevision::decode(legacy_bytes(V0_0_116_POLICY_RECORD).as_slice())
|
|
.expect("legacy policy record must decode");
|
|
assert_eq!(policy.id, "policy-id");
|
|
assert_eq!(policy.sandbox_id, "sandbox-id");
|
|
assert_eq!(policy.version, 2);
|
|
assert_eq!(policy.policy_payload, [1, 2, 3]);
|
|
assert_eq!(policy.policy_hash, "sha256");
|
|
assert_eq!(policy.status, "loaded");
|
|
assert_eq!(policy.load_error.as_deref(), Some("none"));
|
|
assert_eq!(policy.created_at_ms, 250);
|
|
assert_eq!(policy.loaded_at_ms, Some(300));
|
|
assert_eq!(policy.provenance["source"], "fixture");
|
|
|
|
let draft = StoredDraftChunk::decode(legacy_bytes(V0_0_116_DRAFT_RECORD).as_slice())
|
|
.expect("legacy draft record must decode");
|
|
assert_eq!(draft.id, "chunk-id");
|
|
assert_eq!(draft.sandbox_id, "sandbox-id");
|
|
assert_eq!(draft.draft_version, 2);
|
|
assert_eq!(draft.status, "pending");
|
|
assert_eq!(draft.rule_name, "rule");
|
|
assert_eq!(draft.proposed_rule, [4, 5]);
|
|
assert_eq!(draft.rationale, "fixture");
|
|
assert_eq!(draft.confidence, 0.75);
|
|
assert_eq!(draft.created_at_ms, 350);
|
|
assert_eq!(draft.decided_at_ms, Some(400));
|
|
assert_eq!(draft.host, "example.com");
|
|
assert_eq!(draft.port, 443);
|
|
assert_eq!(draft.hit_count, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v0_0_116_database_payloads_survive_current_migrations() {
|
|
use crate::persistence::{DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE, Store};
|
|
use crate::policy_store::{draft_chunk_record_from_parts, policy_record_from_parts};
|
|
|
|
let tempdir = tempfile::tempdir().expect("temporary database directory");
|
|
let database_path = tempdir.path().join("v0.0.116.db");
|
|
let database_url = format!("sqlite://{}", database_path.display());
|
|
|
|
// v0.0.116 and this change share migrations 001-006. Populate that
|
|
// historical on-disk shape with bytes emitted by the v0.0.116 schema,
|
|
// then reopen it through the current migration and loader path.
|
|
let old_store = Store::connect(&database_url)
|
|
.await
|
|
.expect("create legacy database");
|
|
let fixtures = [
|
|
(
|
|
"provider_credential_refresh_state",
|
|
"legacy-id",
|
|
"legacy-name",
|
|
"default",
|
|
V0_0_116_REFRESH_STATE,
|
|
),
|
|
(
|
|
"provider_profile",
|
|
"legacy-profile-id",
|
|
"legacy-profile",
|
|
"",
|
|
V0_0_116_PROFILE,
|
|
),
|
|
(
|
|
POLICY_OBJECT_TYPE,
|
|
"policy-id",
|
|
"",
|
|
"default",
|
|
V0_0_116_POLICY_PAYLOAD,
|
|
),
|
|
(
|
|
DRAFT_CHUNK_OBJECT_TYPE,
|
|
"chunk-id",
|
|
"",
|
|
"default",
|
|
V0_0_116_DRAFT_PAYLOAD,
|
|
),
|
|
];
|
|
for (object_type, id, name, workspace, payload) in fixtures {
|
|
old_store
|
|
.put(
|
|
object_type,
|
|
id,
|
|
name,
|
|
workspace,
|
|
&legacy_bytes(payload),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("insert v0.0.116 fixture");
|
|
}
|
|
old_store.close().await;
|
|
|
|
let current_store = Store::connect(&database_url)
|
|
.await
|
|
.expect("current migrations must accept legacy database");
|
|
for (object_type, id, _, _, payload) in fixtures {
|
|
let record = current_store
|
|
.get(object_type, id)
|
|
.await
|
|
.expect("load fixture row")
|
|
.expect("fixture row must remain present");
|
|
assert_eq!(record.payload, legacy_bytes(payload));
|
|
}
|
|
|
|
let refresh = current_store
|
|
.get_message::<StoredProviderCredentialRefreshStateV2>("legacy-id")
|
|
.await
|
|
.expect("decode refresh fixture")
|
|
.expect("refresh fixture must remain present");
|
|
assert_eq!(refresh.provider_id, "provider-id");
|
|
assert_eq!(refresh.get_resource_version(), 1);
|
|
|
|
let profile = current_store
|
|
.get_message::<StoredProviderProfile>("legacy-profile-id")
|
|
.await
|
|
.expect("decode profile fixture")
|
|
.expect("profile fixture must remain present");
|
|
assert_eq!(profile.profile.expect("profile").display_name, "Legacy");
|
|
|
|
let policy_record = current_store
|
|
.get(POLICY_OBJECT_TYPE, "policy-id")
|
|
.await
|
|
.expect("load policy fixture")
|
|
.expect("policy fixture must remain present");
|
|
let policy = policy_record_from_parts(
|
|
policy_record.id,
|
|
"sandbox-id".to_string(),
|
|
2,
|
|
"loaded".to_string(),
|
|
&policy_record.payload,
|
|
policy_record.created_at_ms,
|
|
)
|
|
.expect("current policy loader must decode v0.0.116 payload");
|
|
assert_eq!(policy.policy_hash, "sha256");
|
|
assert_eq!(policy.provenance["source"], "fixture");
|
|
|
|
let draft_record = current_store
|
|
.get(DRAFT_CHUNK_OBJECT_TYPE, "chunk-id")
|
|
.await
|
|
.expect("load draft fixture")
|
|
.expect("draft fixture must remain present");
|
|
let draft = draft_chunk_record_from_parts(
|
|
draft_record.id,
|
|
"sandbox-id".to_string(),
|
|
"pending".to_string(),
|
|
1,
|
|
&draft_record.payload,
|
|
draft_record.created_at_ms,
|
|
draft_record.updated_at_ms,
|
|
)
|
|
.expect("current draft loader must decode v0.0.116 payload");
|
|
assert_eq!(draft.rule_name, "rule");
|
|
assert_eq!(draft.host, "example.com");
|
|
assert_eq!(draft.port, 443);
|
|
}
|
|
}
|