Skip to content
Draft
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
38 changes: 29 additions & 9 deletions src/app/src/types/factory_reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,39 @@ use std::fmt;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum FactoryResetStatus {
/// No result received yet. Distinct from `Unrecognized` so the UI can stay
/// silent while a result is missing and still report one it cannot name.
#[default]
Unknown,
ModeSupported,
ModeUnsupported,
BackupRestoreError,
ConfigurationError,
Success,
Invalid,
Error,
ConfigError,
/// Reset succeeded, but a partition needed a second format attempt.
Warning,
/// A status code this version does not know.
Unrecognized,
}

impl FactoryResetStatus {
/// `Warning` counts as success: the reset completed, only a partition
/// needed a retry.
#[must_use]
pub const fn is_success(self) -> bool {
matches!(self, Self::Success | Self::Warning)
}
}

impl fmt::Display for FactoryResetStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown => write!(f, "unknown"),
Self::ModeSupported => write!(f, "modeSupported"),
Self::ModeUnsupported => write!(f, "modeUnsupported"),
Self::BackupRestoreError => write!(f, "backupRestoreError"),
Self::ConfigurationError => write!(f, "configurationError"),
Self::Success => write!(f, "success"),
Self::Invalid => write!(f, "invalid"),
Self::Error => write!(f, "error"),
Self::ConfigError => write!(f, "configError"),
Self::Warning => write!(f, "warning"),
Self::Unrecognized => write!(f, "unrecognized"),
}
}
}
Expand All @@ -31,8 +48,11 @@ impl fmt::Display for FactoryResetStatus {
pub struct FactoryResetResult {
pub status: FactoryResetStatus,
pub context: Option<String>,
pub error: String,
pub error: Option<String>,
pub paths: Vec<String>,
/// `true` once the reset started wiping data. On a failure this separates a
/// safe abort from one that left the device half wiped.
pub data_wiped: bool,
}

/// Factory reset state from WebSocket
Expand Down
39 changes: 27 additions & 12 deletions src/app/src/types/ods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,34 +170,48 @@ impl From<OdsNetworkStatus> for NetworkStatus {
}
}

/// Factory reset result status — ODS sends numeric values (`serde_repr`)
/// Factory reset result status — ODS sends numeric values (`serde_repr`).
/// `u32` matches the serialization in omnect-os-init.
#[derive(Debug, Clone, Deserialize_repr, PartialEq, Eq)]
#[repr(u8)]
#[repr(u32)]
pub enum OdsFactoryResetResultStatus {
ModeSupported = 0,
ModeUnsupported = 1,
BackupRestoreError = 2,
ConfigurationError = 3,
Success = 0,
Invalid = 1,
Error = 2,
ConfigError = 3,
Warning = 4,
/// A future status code must parse instead of failing the whole update.
#[serde(other)]
Unrecognized = u32::MAX,
}

impl From<OdsFactoryResetResultStatus> for FactoryResetStatus {
fn from(ods: OdsFactoryResetResultStatus) -> Self {
match ods {
OdsFactoryResetResultStatus::ModeSupported => Self::ModeSupported,
OdsFactoryResetResultStatus::ModeUnsupported => Self::ModeUnsupported,
OdsFactoryResetResultStatus::BackupRestoreError => Self::BackupRestoreError,
OdsFactoryResetResultStatus::ConfigurationError => Self::ConfigurationError,
OdsFactoryResetResultStatus::Success => Self::Success,
OdsFactoryResetResultStatus::Invalid => Self::Invalid,
OdsFactoryResetResultStatus::Error => Self::Error,
OdsFactoryResetResultStatus::ConfigError => Self::ConfigError,
OdsFactoryResetResultStatus::Warning => Self::Warning,
OdsFactoryResetResultStatus::Unrecognized => Self::Unrecognized,
}
}
}

/// Factory reset result
/// Factory reset result.
/// ODS sends twin reports as merge patches, so every optional key arrives
/// explicitly as `null` rather than being omitted.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct OdsFactoryResetResult {
pub status: OdsFactoryResetResultStatus,
#[serde(default)]
pub context: Option<String>,
pub error: String,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub data_wiped: bool,
}

impl From<OdsFactoryResetResult> for FactoryResetResult {
Expand All @@ -207,6 +221,7 @@ impl From<OdsFactoryResetResult> for FactoryResetResult {
context: ods.context,
error: ods.error,
paths: ods.paths,
data_wiped: ods.data_wiped,
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/app/src/update/device/network/verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,11 @@ mod tests {
factory_reset: Some(FactoryReset {
keys: vec!["key1".to_string()],
result: Some(FactoryResetResult {
status: FactoryResetStatus::ModeSupported,
status: FactoryResetStatus::Success,
context: None,
error: String::new(),
error: None,
paths: vec![],
data_wiped: true,
}),
}),
..Default::default()
Expand Down
104 changes: 94 additions & 10 deletions src/app/src/update/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,20 +199,104 @@ mod tests {
assert_eq!(model.factory_reset, Some(expected_status));
}

#[test]
fn parses_integer_status_from_ods() {
fn parse_result(json: &str) -> crate::types::FactoryResetResult {
let mut model = Model::default();
let _ = handle(WebSocketEvent::FactoryResetUpdated(json.into()), &mut model);
model
.factory_reset
.expect("factory_reset should be set")
.result
.expect("result should be set")
}

#[test]
fn parses_successful_result() {
let result = parse_result(
r#"{"keys":["network"],"result":{"status":0,"error":null,"context":null,"paths":["network"],"data_wiped":true}}"#,
);

// ODS sends status as integer (serde_repr): 0=ModeSupported, 1=ModeUnsupported, etc.
let json = r#"{"keys":["network"],"result":{"status":0,"error":"0","paths":["/etc/systemd/network/"]}}"#;
assert_eq!(result.status, FactoryResetStatus::Success);
assert!(result.status.is_success());
assert_eq!(result.error, None);
assert_eq!(result.context, None);
assert_eq!(result.paths, vec!["network"]);
assert!(result.data_wiped);
}

let _ = handle(WebSocketEvent::FactoryResetUpdated(json.into()), &mut model);
#[test]
fn parses_error_result_with_message() {
let result = parse_result(
r#"{"keys":["network"],"result":{"status":2,"error":"format failed","context":"factory partition","paths":[],"data_wiped":true}}"#,
);

let factory_reset = model.factory_reset.expect("factory_reset should be set");
let result = factory_reset.result.expect("result should be set");
assert_eq!(result.status, FactoryResetStatus::ModeSupported);
assert_eq!(result.error, "0");
assert_eq!(result.paths, vec!["/etc/systemd/network/"]);
assert_eq!(result.status, FactoryResetStatus::Error);
assert!(!result.status.is_success());
assert_eq!(result.error.as_deref(), Some("format failed"));
assert_eq!(result.context.as_deref(), Some("factory partition"));
assert!(result.data_wiped);
}

#[test]
fn warning_counts_as_success() {
let result = parse_result(
r#"{"keys":["network"],"result":{"status":4,"error":null,"context":"second format attempt","paths":[],"data_wiped":true}}"#,
);

assert_eq!(result.status, FactoryResetStatus::Warning);
assert!(result.status.is_success());
}

#[test]
fn abort_before_wipe_reports_data_not_wiped() {
let result = parse_result(
r#"{"keys":["network"],"result":{"status":1,"error":"unknown preserve key","context":null,"paths":[],"data_wiped":false}}"#,
);

assert_eq!(result.status, FactoryResetStatus::Invalid);
assert!(!result.data_wiped);
}

#[test]
fn config_error_maps_to_config_error() {
let result = parse_result(
r#"{"keys":["network"],"result":{"status":3,"error":"bad config","context":null,"paths":[],"data_wiped":false}}"#,
);

assert_eq!(result.status, FactoryResetStatus::ConfigError);
}

#[test]
fn unknown_status_code_parses_as_unrecognized() {
// ODS reports a status it does not know itself as u32::MAX; any
// future code must not fail the whole update.
let result = parse_result(
r#"{"keys":["network"],"result":{"status":4294967295,"error":null,"context":null,"paths":[],"data_wiped":false}}"#,
);

assert_eq!(result.status, FactoryResetStatus::Unrecognized);
assert!(!result.status.is_success());
}

#[test]
fn unrecognized_status_is_distinct_from_no_result() {
// The UI stays silent on Unknown (no result yet) but must still
// report a result whose status it cannot name.
assert_ne!(
FactoryResetStatus::Unrecognized,
FactoryResetStatus::Unknown
);
assert_eq!(FactoryResetStatus::default(), FactoryResetStatus::Unknown);
}

#[test]
fn missing_optional_keys_fall_back_to_defaults() {
let result = parse_result(r#"{"keys":["network"],"result":{"status":0}}"#);

assert_eq!(result.status, FactoryResetStatus::Success);
assert_eq!(result.error, None);
assert_eq!(result.context, None);
assert!(result.paths.is_empty());
assert!(!result.data_wiped);
}
}

Expand Down
22 changes: 14 additions & 8 deletions src/backend/src/omnect_device_service_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ impl Clone for MockDeviceServiceClient {
}

impl OmnectDeviceServiceClient {
const REQUIRED_CLIENT_VERSION: &str = ">=0.39.0";
// The floor is the oldest omnect-device-service that publishes the
// factory-reset result in the shape parsed here (feature version 4).
// TODO: remove me, as soon as the omnect-device-service release carrying
// factory-reset feature version 4 is tagged — replace 0.46.0 with that tag.
const REQUIRED_CLIENT_VERSION: &str = ">=0.46.0";

// API endpoint constants
const STATUS_ENDPOINT: &str = "/status/v1";
Expand Down Expand Up @@ -406,24 +410,26 @@ mod tests {
#[test]
fn required_version_parses_correctly() {
let version_req = OmnectDeviceServiceClient::required_version();
assert_eq!(version_req.to_string(), ">=0.39.0");
assert_eq!(version_req.to_string(), ">=0.46.0");
}

#[test]
fn required_version_matches_valid_versions() {
let version_req = OmnectDeviceServiceClient::required_version();

assert!(version_req.matches(&Version::parse("0.39.0").unwrap()));
assert!(version_req.matches(&Version::parse("0.40.0").unwrap()));
assert!(version_req.matches(&Version::parse("0.46.0").unwrap()));
assert!(version_req.matches(&Version::parse("0.47.0").unwrap()));
assert!(version_req.matches(&Version::parse("1.0.0").unwrap()));
}

#[test]
fn required_version_rejects_older_versions() {
let version_req = OmnectDeviceServiceClient::required_version();

assert!(!version_req.matches(&Version::parse("0.38.9").unwrap()));
assert!(!version_req.matches(&Version::parse("0.30.0").unwrap()));
// 0.45.2 is the last release publishing the factory-reset result in
// the previous shape.
assert!(!version_req.matches(&Version::parse("0.45.2").unwrap()));
assert!(!version_req.matches(&Version::parse("0.41.0").unwrap()));
assert!(!version_req.matches(&Version::parse("0.1.0").unwrap()));
}
}
Expand All @@ -449,7 +455,7 @@ mod tests {

#[test]
fn detects_version_mismatch_when_below_requirement() {
let status = create_test_status("0.38.0");
let status = create_test_status("0.45.2");
let current_version = status.system_info.omnect_device_service_version;

let required_version = OmnectDeviceServiceClient::required_version();
Expand All @@ -462,7 +468,7 @@ mod tests {

#[test]
fn detects_no_mismatch_when_matching_requirement() {
let status = create_test_status("0.40.0");
let status = create_test_status("0.46.0");
let current_version = status.system_info.omnect_device_service_version;

let required_version = OmnectDeviceServiceClient::required_version();
Expand Down
9 changes: 8 additions & 1 deletion src/ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import OmnectLogo from "./components/branding/OmnectLogo.vue"
import OverlaySpinner from "./components/feedback/OverlaySpinner.vue"
import UserMenu from "./components/UserMenu.vue"
import { useCore } from "./composables/useCore"
import { isFactoryResetSuccess } from "./composables/core/types"
import { useSnackbar } from "./composables/useSnackbar"
import { useMessageWatchers } from "./composables/useMessageWatchers"

Expand Down Expand Up @@ -93,6 +94,7 @@ const acknowledgeUpdateValidation = () => {
const factoryResetModalSuccess = ref(false)
const factoryResetError = ref<string | null>(null)
const factoryResetContext = ref<string | null>(null)
const factoryResetDataWiped = ref(false)
const updateValidationIsRollback = ref(false)

// Watch authentication state to redirect to login if session is lost
Expand Down Expand Up @@ -132,7 +134,8 @@ watch(
// Snapshot once so the template is decoupled from the live ViewModel during close animation
factoryResetError.value = result.error ?? null
factoryResetContext.value = result.context ?? null
factoryResetModalSuccess.value = result.status === 'modeSupported'
factoryResetModalSuccess.value = isFactoryResetSuccess(result.status)
factoryResetDataWiped.value = result.dataWiped
showFactoryResetResultModal.value = true
}
}
Expand Down Expand Up @@ -200,6 +203,10 @@ watch(
<template v-else>
<p v-if="factoryResetError">{{ factoryResetError }}</p>
<p v-if="factoryResetContext">{{ factoryResetContext }}</p>
<p v-if="factoryResetDataWiped" data-testid="factory-reset-data-wiped">
Device data was already wiped before the reset failed. The device needs a new
factory reset to reach a defined state.
</p>
</template>
<div class="flex justify-end">
<v-btn color="primary" @click="acknowledgeFactoryResetResult">OK</v-btn>
Expand Down
3 changes: 2 additions & 1 deletion src/ui/src/composables/core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ export function updateViewModelFromCore(): void {
? {
status: factoryResetStatusToString(coreViewModel.factoryReset.result.status),
context: coreViewModel.factoryReset.result.context || null,
error: coreViewModel.factoryReset.result.error,
error: coreViewModel.factoryReset.result.error || null,
paths: coreViewModel.factoryReset.result.paths,
dataWiped: coreViewModel.factoryReset.result.dataWiped,
}
: null,
}
Expand Down
Loading
Loading