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
16 changes: 13 additions & 3 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@ modes:
mutation. If they diverge it returns `Conflict` without attempting the
write. Client-facing operations that carry an `expected_resource_version`
field use this mode: `AttachSandboxProvider`, `DetachSandboxProvider`,
`UpdateProvider`, and `UpdateConfig` (policy backfill path).
`UpdateProvider`, `UpdateProviderProfiles`, and `UpdateConfig` (policy
backfill path).

**Lists.** The `list_messages` and `list_messages_with_selector` helpers decode
protobuf payloads from list results and hydrate `resource_version` from the
Expand All @@ -235,7 +236,7 @@ coverage:
|---|---|---|---|
| Sandbox | `MustCreate` | `update_message_cas` | `list_messages` |
| Provider | `MustCreate` | `update_message_cas` | `list_messages` |
| ProviderProfile | `MustCreate` | (immutable) | `list_messages` |
| ProviderProfile | `MustCreate` | `MatchResourceVersion` | `list_messages` |
| InferenceRoute | `MustCreate` | `update_message_cas` | `list_messages` |
| SandboxPolicy | scoped versioning | scoped versioning | scoped query |
| Settings | `Mutex`-guarded | `Mutex`-guarded | single-row |
Expand All @@ -247,7 +248,16 @@ gateways, the Mutex alone would be insufficient. Sandbox-scoped settings
rely entirely on CAS without a Mutex.

The `resource_version` is surfaced to clients through `ObjectMeta` in proto
responses. Database migrations backfill existing rows with version 1.
responses. Provider profiles are the exception: custom profile get/list/export
responses copy the stored version onto the profile payload so exported YAML can
carry the expected version for safe single-profile updates. Database migrations
backfill existing rows with version 1.

Provider profile updates also hold the sandbox synchronization guard while
checking attached-sandbox dynamic token grant ambiguity and writing the updated
profile. Sandbox provider attach/detach uses the same guard, so one gateway
process cannot interleave an attach with a profile update that would leave an
ambiguous final dynamic-token state.

Policy and runtime settings are delivered together through the effective sandbox
config path. A gateway-global policy can override sandbox-scoped policy. The
Expand Down
29 changes: 29 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,14 @@ enum ProviderProfileCommands {
from: Option<PathBuf>,
},

/// Update an existing custom provider profile from a file.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Update {
/// Profile file to update.
#[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)]
file: PathBuf,
},

/// Validate provider profile files without registering them.
#[command(group = clap::ArgGroup::new("source").required(true).args(["file", "from"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Lint {
Expand Down Expand Up @@ -2921,6 +2929,9 @@ async fn main() -> Result<()> {
)
.await?;
}
ProviderProfileCommands::Update { file } => {
run::provider_profile_update(endpoint, &file, &tls).await?;
}
ProviderProfileCommands::Lint { file, from } => {
run::provider_profile_lint(
endpoint,
Expand Down Expand Up @@ -3759,6 +3770,24 @@ mod tests {
})
));

let update = Cli::try_parse_from([
"openshell",
"provider",
"profile",
"update",
"-f",
"./profiles/custom-api.yaml",
])
.expect("provider profile update should parse");
assert!(matches!(
update.command,
Some(Commands::Provider {
command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update {
file: _
}))
})
));

let delete =
Cli::try_parse_from(["openshell", "provider", "profile", "delete", "custom-api"])
.expect("provider profile delete should parse");
Expand Down
68 changes: 58 additions & 10 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ use openshell_core::proto::{
RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy,
SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest,
SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event,
setting_value, tcp_forward_init,
UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest,
exec_sandbox_event, setting_value, tcp_forward_init,
};
use openshell_core::settings::{self, SettingValueKind};
use openshell_core::{ObjectId, ObjectName};
Expand Down Expand Up @@ -4895,6 +4895,21 @@ pub async fn provider_profile_export(
output: &str,
tls: &TlsOptions,
) -> Result<()> {
let rendered = provider_profile_export_text(server, id, output, tls).await?;
if output == "json" {
println!("{rendered}");
} else {
print!("{rendered}");
}
Ok(())
}

pub async fn provider_profile_export_text(
server: &str,
id: &str,
output: &str,
tls: &TlsOptions,
) -> Result<String> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_provider_profile(GetProviderProfileRequest { id: id.to_string() })
Expand All @@ -4906,16 +4921,14 @@ pub async fn provider_profile_export(
.ok_or_else(|| miette!("provider profile '{id}' not found"))?;
let profile = ProviderTypeProfile::from_proto(&profile);

if !crate::output::print_output_direct(
output,
|| profile_to_json(&profile).into_diagnostic(),
|| profile_to_yaml(&profile).into_diagnostic(),
)? {
return Err(miette!(
match output {
"json" => profile_to_json(&profile).into_diagnostic(),
"yaml" => profile_to_yaml(&profile).into_diagnostic(),
"table" => Err(miette!(
"profile export supports '-o yaml' and '-o json'; table output is not supported"
));
)),
_ => Err(miette!("unsupported output format: {output}")),
}
Ok(())
}

pub async fn provider_profile_import(
Expand Down Expand Up @@ -4959,6 +4972,41 @@ pub async fn provider_profile_import(
Err(miette!("provider profile import failed"))
}

pub async fn provider_profile_update(server: &str, file: &Path, tls: &TlsOptions) -> Result<()> {
let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?;
if items.is_empty() && diagnostics.is_empty() {
return Err(miette!("no provider profile files found"));
}
if profile_diagnostics_have_errors(&diagnostics) {
print_profile_diagnostics(&diagnostics);
return Err(miette!("provider profile update failed"));
}

let mut client = grpc_client(server, tls).await?;
if let Some(item) = items.pop() {
let expected_resource_version = item
.profile
.as_ref()
.map_or(0, |profile| profile.resource_version);
let response = client
.update_provider_profiles(UpdateProviderProfilesRequest {
profile: Some(item),
expected_resource_version,
})
.await
.into_diagnostic()?
.into_inner();
diagnostics.extend(response.diagnostics);
if response.updated {
println!("Updated provider profile.");
return Ok(());
}
}

print_profile_diagnostics(&diagnostics);
Err(miette!("provider profile update failed"))
}

pub async fn provider_profile_lint(
server: &str,
file: Option<&Path>,
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn update_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::UpdateProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::UpdateProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn lint_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::LintProviderProfilesRequest>,
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn update_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::UpdateProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::UpdateProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn lint_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::LintProviderProfilesRequest>,
Expand Down
76 changes: 76 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,10 @@ impl OpenShell for TestOpenShell {
.profiles
.into_iter()
.filter_map(|item| item.profile)
.map(|mut profile| {
profile.resource_version = 1;
profile
})
.inspect(|profile| {
profiles.insert(profile.id.clone(), profile.clone());
})
Expand All @@ -468,6 +472,53 @@ impl OpenShell for TestOpenShell {
))
}

async fn update_provider_profiles(
&self,
request: tonic::Request<openshell_core::proto::UpdateProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::UpdateProviderProfilesResponse>, Status> {
let mut profiles = self.state.profiles.lock().await;
let request = request.into_inner();
let mut profile = request
.profile
.and_then(|item| item.profile)
.ok_or_else(|| Status::invalid_argument("provider profile is required"))?;
let Some(current) = profiles.get(&profile.id) else {
return Ok(Response::new(
openshell_core::proto::UpdateProviderProfilesResponse {
diagnostics: vec![openshell_core::proto::ProviderProfileDiagnostic {
source: profile.id.clone(),
profile_id: profile.id.clone(),
field: "id".to_string(),
message: format!("custom provider profile '{}' does not exist", profile.id),
severity: "error".to_string(),
}],
profile: None,
updated: false,
},
));
};
let expected_resource_version = if request.expected_resource_version != 0 {
request.expected_resource_version
} else {
profile.resource_version
};
if expected_resource_version == 0 || expected_resource_version != current.resource_version {
return Err(Status::aborted(format!(
"provider profile was modified concurrently (current resource_version: {})",
current.resource_version
)));
}
profile.resource_version = current.resource_version + 1;
profiles.insert(profile.id.clone(), profile.clone());
Ok(Response::new(
openshell_core::proto::UpdateProviderProfilesResponse {
diagnostics: Vec::new(),
profile: Some(profile),
updated: true,
},
))
}

async fn lint_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::LintProviderProfilesRequest>,
Expand Down Expand Up @@ -1300,6 +1351,31 @@ binaries: [/usr/bin/custom]
run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls)
.await
.expect("profile import");
let exported_yaml =
run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", &ts.tls)
.await
.expect("profile export text");
assert!(exported_yaml.contains("resource_version: 1"));
let updated_yaml = exported_yaml
.replace(
"display_name: Custom API",
"display_name: Custom API Updated",
)
.replace("host: api.custom.example", "host: api.updated.example");
std::fs::write(&profile_path, updated_yaml).unwrap();
run::provider_profile_update(&ts.endpoint, &profile_path, &ts.tls)
.await
.expect("profile update");
assert_eq!(
ts.state
.profiles
.lock()
.await
.get("custom-api")
.and_then(|profile| profile.endpoints.first())
.map(|endpoint| endpoint.host.as_str()),
Some("api.updated.example")
);
run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", &ts.tls)
.await
.expect("profile export");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn update_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::UpdateProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::UpdateProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn lint_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::LintProviderProfilesRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn update_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::UpdateProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::UpdateProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn lint_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::LintProviderProfilesRequest>,
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-providers/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ mod tests {
fn profile() -> ProviderTypeProfile {
ProviderTypeProfile {
id: "custom".to_string(),
resource_version: 0,
display_name: "Custom".to_string(),
description: String::new(),
category: openshell_core::proto::ProviderProfileCategory::Other,
Expand Down
Loading
Loading