Skip to content
8 changes: 2 additions & 6 deletions core/crates/ployz-core/src/domain/data_loss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,8 @@ impl UnconfirmedDataLoss {
/// Refusal when execute-time Data Loss is not covered by the confirmation.
#[must_use]
pub fn into_rpc_error(self) -> RpcError {
RpcError {
code: RpcErrorCode::InvalidArgument,
message: self.to_string(),
details: serde_json::to_value(&self).expect("UnconfirmedDataLoss is JSON"),
cause: Vec::new(),
}
RpcError::invalid_argument(self.to_string())
.with_details(serde_json::to_value(&self).expect("UnconfirmedDataLoss is JSON"))
}

/// Execute-time refusal payload, when `error` carries one.
Expand Down
17 changes: 17 additions & 0 deletions core/crates/ployz-core/src/domain/management_capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ pub const TUNNEL_OPEN: u8 = 0;
/// reason says which.
pub const TUNNEL_FAILED: u32 = 0x53;

/// QUIC application close code a Machine sends a key it does not admit: never admitted, or
/// rotated away.
pub const CLIENT_REFUSED_CLOSE: u32 = 0x50;

/// QUIC application close code a Machine sends a live connection whose key no slot holds
/// any more.
pub const REVOKED_CLOSE: u32 = 0x51;

/// QUIC application close code a Machine sends to confirm, authenticated, that the dialing
/// key was cleared: a Cleared tombstone holds it.
pub const CLIENT_CLEARED_CLOSE: u32 = 0x52;

/// Whether a management-side endpoint sends with UDP segmentation offload. It does not: a
/// NIC without UDP checksum offload refuses a segmented send, and the socket only learns
/// that by dropping one, which stalls the first large send on a fresh path (#3567).
pub const SEGMENTATION_OFFLOAD: bool = false;

/// The [`TUNNEL_FAILED`] close reason for a client below `Full` scope, matched whole. A
/// v0.3.4 CLI prints it as it is, and knows no other close code, so it names the way on.
pub const TUNNEL_NEEDS_FULL: &str = "tunnels need Full access: run `ployz device request` (update ployz first if it has no `device` command)";
Expand Down
168 changes: 158 additions & 10 deletions core/crates/ployz-core/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2217,28 +2217,100 @@ pub struct RpcError {
pub cause: Vec<String>,
}

/// The code of a refusal on the caller's access, such as a Server refusing a scope.
pub const FORBIDDEN_CODE: &str = "forbidden";

/// The code of a refusal that waits on a person's approval.
pub const APPROVAL_REQUIRED_CODE: &str = "approval_required";

macro_rules! named_constructors {
($($(#[$meta:meta])* $name:ident => $code:ident,)*) => {
$(
$(#[$meta])*
#[must_use]
pub fn $name(message: impl Into<String>) -> Self {
Self::new(RpcErrorCode::$code, message)
}
)*
};
}

impl RpcError {
/// `error`'s own sentence as the message, and every source below it as a cause.
/// An error with no details and no causes.
#[must_use]
pub fn caused(code: RpcErrorCode, error: &(dyn std::error::Error + 'static)) -> Self {
pub fn new(code: RpcErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: error.to_string(),
message: message.into(),
details: Value::Null,
cause: crate::error_chain::causes(error),
cause: Vec::new(),
}
}

named_constructors! {
invalid_argument => InvalidArgument,
not_found => NotFound,
ambiguous => Ambiguous,
unsupported => Unsupported,
unavailable => Unavailable,
conflict => Conflict,
internal => Internal,
unauthenticated => Unauthenticated,
confirmation_required => ConfirmationRequired,
}

/// A refusal on the caller's access, coded [`FORBIDDEN_CODE`].
#[must_use]
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(RpcErrorCode::from(FORBIDDEN_CODE), message)
}

/// A refusal that waits on a person's approval, coded [`APPROVAL_REQUIRED_CODE`].
#[must_use]
pub fn approval_required(message: impl Into<String>) -> Self {
Self::new(RpcErrorCode::from(APPROVAL_REQUIRED_CODE), message)
}

#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}

#[must_use]
pub fn with_details(mut self, details: Value) -> Self {
self.details = details;
self
}

/// `self` with `error`'s own sentence and every source below it as its causes.
#[must_use]
pub fn with_cause(mut self, error: &(dyn std::error::Error + 'static)) -> Self {
self.cause = std::iter::once(error.to_string())
.chain(crate::error_chain::causes(error))
.collect();
self
}

/// `self` with `cause` as its causes, outermost first.
#[must_use]
pub fn with_causes(mut self, cause: Vec<String>) -> Self {
self.cause = cause;
self
}

/// `error`'s own sentence as the message, and every source below it as a cause.
#[must_use]
pub fn caused(code: RpcErrorCode, error: &(dyn std::error::Error + 'static)) -> Self {
Self::new(code, error.to_string()).with_causes(crate::error_chain::causes(error))
}

/// An error the caller made because its target Machine didn't answer: the target
/// sent nothing, so it may answer later. Read back by [`Self::target_unanswered`].
#[must_use]
pub fn unanswered_target(code: RpcErrorCode, message: String) -> Self {
Self {
code,
message,
details: serde_json::json!({ "reason": TARGET_UNANSWERED_REASON }),
cause: Vec::new(),
}
Self::new(code, message)
.with_details(serde_json::json!({ "reason": TARGET_UNANSWERED_REASON }))
}

/// `self` from a call that failed while a Leave was removing `server`, so its copy
Expand Down Expand Up @@ -2657,6 +2729,82 @@ mod rpc_error_wire {
use super::*;
use serde_json::json;

#[test]
fn constructors_serialize_like_the_literals_they_replace() {
let literal = |code: RpcErrorCode, details: Value, cause: Vec<String>| RpcError {
code,
message: "m".into(),
details,
cause,
};
let wire = |error: &RpcError| serde_json::to_string(error).unwrap();
for (built, code) in [
(
RpcError::invalid_argument("m"),
RpcErrorCode::InvalidArgument,
),
(RpcError::not_found("m"), RpcErrorCode::NotFound),
(RpcError::ambiguous("m"), RpcErrorCode::Ambiguous),
(RpcError::unsupported("m"), RpcErrorCode::Unsupported),
(RpcError::unavailable("m"), RpcErrorCode::Unavailable),
(RpcError::conflict("m"), RpcErrorCode::Conflict),
(RpcError::internal("m"), RpcErrorCode::Internal),
(
RpcError::unauthenticated("m"),
RpcErrorCode::Unauthenticated,
),
(
RpcError::confirmation_required("m"),
RpcErrorCode::ConfirmationRequired,
),
(RpcError::forbidden("m"), RpcErrorCode::from("forbidden")),
(
RpcError::approval_required("m"),
RpcErrorCode::from("approval_required"),
),
(
RpcError::new(RpcErrorCode::Conflict, "m"),
RpcErrorCode::Conflict,
),
] {
let expected = literal(code, Value::Null, Vec::new());
assert_eq!(built, expected);
assert_eq!(wire(&built), wire(&expected));
}
assert_eq!(
wire(&RpcError::forbidden("m")),
r#"{"code":"forbidden","message":"m","details":null}"#
);

let detailed = RpcError::conflict("m")
.with_details(json!({ "k": 1 }))
.with_causes(vec!["a".into(), "b".into()]);
assert_eq!(
wire(&detailed),
wire(&literal(
RpcErrorCode::Conflict,
json!({ "k": 1 }),
vec!["a".into(), "b".into()]
))
);
assert_eq!(
RpcError::conflict("m").with_message("n"),
RpcError {
message: "n".into(),
..literal(RpcErrorCode::Conflict, Value::Null, Vec::new())
}
);
}

#[test]
fn with_cause_lists_the_error_then_its_sources() {
let inner = std::io::Error::other("disk");
let outer = RpcError::caused(RpcErrorCode::Internal, &inner);
assert_eq!(outer.cause, Vec::<String>::new());
let chained = RpcError::internal("m").with_cause(&inner);
assert_eq!(chained.cause, vec!["disk".to_owned()]);
}

#[test]
fn rpc_error_round_trips_cause() {
let error = RpcError {
Expand Down
16 changes: 6 additions & 10 deletions core/crates/ployz-core/src/storage_capacity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,16 +189,12 @@ impl StorageCapacityError {
]),
);
}
RpcError {
code: if matches!(self, Self::StorageCapacityUnknown { .. }) {
RpcErrorCode::Unavailable
} else {
RpcErrorCode::Conflict
},
message: self.to_string(),
details,
cause: Vec::new(),
}
let code = if matches!(self, Self::StorageCapacityUnknown { .. }) {
RpcErrorCode::Unavailable
} else {
RpcErrorCode::Conflict
};
RpcError::new(code, self.to_string()).with_details(details)
}
}

Expand Down
58 changes: 58 additions & 0 deletions core/crates/ployz-core/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,44 @@ validated_string_newtype!(
&& bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
);
/// The namespace a [`ManagementClientLabel`] names, read from its text.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagementClientKind {
Cloud,
Cli,
Device,
Other,
}

impl ManagementClientLabel {
const CLOUD: &'static str = "cloud";
const CLI_PREFIX: &'static str = "cli-";

/// The label Cloud's slot carries.
#[must_use]
pub fn cloud() -> Self {
Self::parse(Self::CLOUD).expect("`cloud` is a valid Management Client label")
}

#[must_use]
pub fn is_cloud(&self) -> bool {
self.as_str() == Self::CLOUD
}

#[must_use]
pub fn kind(&self) -> ManagementClientKind {
if self.is_cloud() {
ManagementClientKind::Cloud
} else if self.as_str().starts_with(Self::CLI_PREFIX) {
ManagementClientKind::Cli
} else if crate::DeviceCode::reserves(self) {
ManagementClientKind::Device
} else {
ManagementClientKind::Other
}
}
}

validated_string_newtype!(
DockerVolumeName,
"Docker Volume name",
Expand Down Expand Up @@ -1278,6 +1316,26 @@ mod tests {

use super::*;

#[test]
fn a_management_client_label_names_its_kind() {
for (label, kind) in [
("cloud", ManagementClientKind::Cloud),
(
"cli-0123456789abcdef0123456789ab",
ManagementClientKind::Cli,
),
("device-abc", ManagementClientKind::Device),
("laptop", ManagementClientKind::Other),
("cloud-2", ManagementClientKind::Other),
("cli", ManagementClientKind::Other),
] {
let label = ManagementClientLabel::parse(label).unwrap();
assert_eq!(label.kind(), kind, "{label}");
assert_eq!(label.is_cloud(), kind == ManagementClientKind::Cloud);
}
assert_eq!(ManagementClientLabel::cloud().as_str(), "cloud");
}

#[test]
fn a_hostname_normalises_to_a_machine_name() {
let long = format!("{}-tail", "a".repeat(66));
Expand Down
8 changes: 2 additions & 6 deletions core/crates/ployz-core/src/volume_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,8 @@ impl SetVolumeCopyRefused {
| CopyRefusal::NoSource
| CopyRefusal::Stale => RpcErrorCode::Conflict,
};
RpcError {
code,
message: message.into(),
details: serde_json::to_value(self).expect("a copy refusal is JSON serializable"),
cause: Vec::new(),
}
RpcError::new(code, message)
.with_details(serde_json::to_value(self).expect("a copy refusal is JSON serializable"))
}
}

Expand Down
8 changes: 2 additions & 6 deletions core/crates/ployz-core/src/volume_size.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,8 @@ impl SetVolumeSizeRefused {
| SizeRefusal::Shrink
| SizeRefusal::BelowFloor => RpcErrorCode::Conflict,
};
RpcError {
code,
message: message.into(),
details: serde_json::to_value(self).expect("a size refusal is JSON serializable"),
cause: Vec::new(),
}
RpcError::new(code, message)
.with_details(serde_json::to_value(self).expect("a size refusal is JSON serializable"))
}
}

Expand Down
8 changes: 2 additions & 6 deletions core/crates/ployz-core/src/volume_switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,12 +457,8 @@ impl SwitchError {
/// An RPC error that carries this reason in `details`.
#[must_use]
pub fn rpc_error(self, message: impl Into<String>) -> RpcError {
RpcError {
code: self.rpc_code(),
message: message.into(),
details: serde_json::to_value(self).expect("a switch reason is JSON serializable"),
cause: Vec::new(),
}
RpcError::new(self.rpc_code(), message)
.with_details(serde_json::to_value(self).expect("a switch reason is JSON serializable"))
}

const fn rpc_code(self) -> RpcErrorCode {
Expand Down
Loading
Loading