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: 53 additions & 8 deletions crates/openshell-core/src/extension_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,23 @@ pub struct NegotiatedExtension {
#[derive(Debug, Error, PartialEq, Eq)]
pub enum NegotiationError {
#[error(
"{family} extension '{name}' did not provide protocol metadata; upgrade the extension to a version that supports OpenShell extension negotiation"
"{family} extension '{name}' did not provide protocol metadata; upgrade the extension to a version that supports OpenShell extension negotiation (expected {expected})"
)]
MissingMetadata { family: &'static str, name: String },
MissingMetadata {
family: &'static str,
name: String,
expected: String,
},
#[error(
"gateway did not provide protocol metadata to {family} extension '{name}'; upgrade the gateway and extension together"
)]
MissingGatewayMetadata { family: &'static str, name: String },
#[error("{family} extension '{name}' did not provide a protocol version")]
MissingProtocolVersion { family: &'static str, name: String },
#[error("{family} extension '{name}' did not provide a protocol version (expected {expected})")]
MissingProtocolVersion {
family: &'static str,
name: String,
expected: String,
},
#[error(
"{family} extension '{name}' uses unsupported protocol {remote_major}.{remote_minor}; gateway supports {local_major}.{local_minor}"
)]
Expand Down Expand Up @@ -148,6 +156,14 @@ pub fn extension_metadata(
}
}

/// Protocol version and base capability an extension of `family` must declare.
fn expected_contract(family: ExtensionFamily) -> String {
format!(
"protocol {PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and capability {}",
family.contract_capability()
)
}

pub fn negotiate(
family: ExtensionFamily,
configured_name: impl Into<String>,
Expand All @@ -156,20 +172,24 @@ pub fn negotiate(
) -> Result<NegotiatedExtension, NegotiationError> {
let configured_name = configured_name.into();
let family_name = family.as_str();
let expected = expected_contract(family);
let extension = extension.ok_or_else(|| NegotiationError::MissingMetadata {
family: family_name,
name: configured_name.clone(),
expected: expected.clone(),
})?;
let version = extension.protocol_version.as_ref().ok_or_else(|| {
NegotiationError::MissingProtocolVersion {
family: family_name,
name: configured_name.clone(),
expected: expected.clone(),
}
})?;
let gateway_version = gateway.protocol_version.as_ref().ok_or_else(|| {
NegotiationError::MissingProtocolVersion {
family: family_name,
name: "gateway".to_string(),
expected,
}
})?;
if version.major != gateway_version.major {
Expand Down Expand Up @@ -401,10 +421,13 @@ mod tests {
#[test]
fn missing_metadata_and_major_skew_are_actionable() {
let (gateway, mut extension) = compatible();
assert!(matches!(
negotiate(ExtensionFamily::Compute, "example", &gateway, None),
Err(NegotiationError::MissingMetadata { .. })
));
let missing = negotiate(ExtensionFamily::Compute, "example", &gateway, None).unwrap_err();
assert!(matches!(missing, NegotiationError::MissingMetadata { .. }));
assert!(
missing
.to_string()
.contains("protocol 1.0 and capability openshell.compute.contract")
);
extension.protocol_version.as_mut().unwrap().major = 2;
assert!(matches!(
negotiate(
Expand All @@ -417,6 +440,28 @@ mod tests {
));
}

#[test]
fn missing_protocol_version_names_the_expected_contract() {
let (gateway, mut extension) = compatible();
extension.protocol_version = None;
let error = negotiate(
ExtensionFamily::Compute,
"example",
&gateway,
Some(extension),
)
.unwrap_err();
assert!(matches!(
error,
NegotiationError::MissingProtocolVersion { .. }
));
assert!(
error
.to_string()
.contains("protocol 1.0 and capability openshell.compute.contract")
);
}

#[test]
fn both_peers_require_capabilities_from_the_other() {
let (mut gateway, mut extension) = compatible();
Expand Down
23 changes: 23 additions & 0 deletions docs/extensibility/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ are satisfied. It rejects different major versions, missing metadata, or an
unmet required capability during startup. Built-in and external service
extensions follow the same compatibility checks.

Every extension returns protocol `1.0` and lists its family's base capability in
`supported_capabilities`:

| Family | Base capability | Returned in |
|---|---|---|
| Compute driver | `openshell.compute.contract` | `GetCapabilitiesResponse.extension` |
| Credential driver | `openshell.credentials.contract` | `GetCredentialDriverCapabilitiesResponse.extension` |
| Gateway interceptor | `openshell.gateway-interceptor.contract` | `InterceptorManifest.extension` |
| Supervisor middleware | `openshell.supervisor-middleware.contract` | `MiddlewareManifest.extension` |

For example, a supervisor middleware `Describe` response includes:

```json
{
"extension": {
"protocol_version": { "major": 1, "minor": 0 },
"implementation_name": "example/content-guard",
"implementation_version": "0.1.0",
"supported_capabilities": ["openshell.supervisor-middleware.contract"]
}
}
```

Use `openshell gateway info` to inspect the negotiated extension families,
implementation versions, protocol versions, and capabilities active on a
gateway.
Expand Down
2 changes: 2 additions & 0 deletions proto/compute_driver.proto
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ message GetCapabilitiesResponse {
// the driver does not support rootfs tar sources.
uint64 rootfs_tar_max_bytes = 11;
// Compute extension protocol metadata. Required for protocol negotiation.
// Declare protocol 1.0 and list "openshell.compute.contract" in
// supported_capabilities.
openshell.extension.v1.PeerMetadata extension = 12;
// Versioned effective operator admission policy (v1: followed by JSON).
// Gateways require an exact policy match before activating the driver.
Expand Down
2 changes: 2 additions & 0 deletions proto/credential_driver.proto
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ message GetCredentialDriverCapabilitiesResponse {
// True when ResolveCredentials may return expiration_time values.
bool supports_expires_at = 5;
// Credential-driver protocol metadata. Required for protocol negotiation.
// Declare protocol 1.0 and list "openshell.credentials.contract" in
// supported_capabilities.
openshell.extension.v1.PeerMetadata extension = 6;
}

Expand Down
2 changes: 2 additions & 0 deletions proto/gateway_interceptor.proto
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ message InterceptorManifest {
// post-authentication consistency check.
string expected_audience = 5;
// Gateway-interceptor protocol metadata. Required for negotiation.
// Declare protocol 1.0 and list "openshell.gateway-interceptor.contract" in
// supported_capabilities.
openshell.extension.v1.PeerMetadata extension = 6;
}

Expand Down
2 changes: 2 additions & 0 deletions proto/supervisor_middleware.proto
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ message MiddlewareManifest {
// this post-authentication consistency check.
string expected_audience = 4;
// Supervisor-middleware protocol metadata. Required for negotiation.
// Declare protocol 1.0 and list "openshell.supervisor-middleware.contract"
// in supported_capabilities.
openshell.extension.v1.PeerMetadata extension = 5;
}

Expand Down
Loading