diff --git a/diagrams/plantuml/ab-storage-layout.puml b/diagrams/plantuml/ab-storage-layout.puml new file mode 100644 index 0000000..7091ca0 --- /dev/null +++ b/diagrams/plantuml/ab-storage-layout.puml @@ -0,0 +1,43 @@ +@startuml +title A/B storage: logical areas and access responsibilities +left to right direction +skinparam shadowing false +skinparam monochrome true + +actor "Installer" as installer +actor "Boot control" as boot +actor "OTA manager /\nconfirmation program" as manager +actor "Running system\nand applications" as runtime + +rectangle "Device storage (logical areas, not a partition table)" { + rectangle "Shared boot components\nSeparate update and recovery method" as shared + database "Boot state\nNormal slot, trial target, attempts, confirmation" as state + rectangle "Slot A: retained system\nKernel + device tree + root filesystem" as A + rectangle "Slot B: update target\nKernel + device tree + root filesystem" as B + database "Persistent device data" as data { + rectangle "Identity and calibration\nRestricted access" as identity + rectangle "Configuration and application data\nMust support the retained version" as appdata + rectangle "OTA progress, failure blocks\nand pending reports" as progress + } + rectangle "Download and work area\nDisposable preparation files" as work +} + +installer --> B : replace inactive slot +installer --> work : use temporary files +boot --> shared : execute +boot --> state : read / save before Linux starts +boot --> A : select matching components +boot --> B : select matching components +manager --> state : request trial / confirm\nthrough platform interface +manager --> progress : read / save +runtime --> appdata : read / write +runtime --> identity : authorized access only + +legend bottom + A is running while B is prepared. Preserve A, boot state and device data. + Persistent device data survives replacement of either system slot. + Shared data is not rolled back by selecting A. + Both system versions must understand the recovery records they read. + Separate logical areas do not guarantee safe writes or whole-device recovery. +endlegend +@enduml diff --git a/diagrams/plantuml/ab-trial-boot.puml b/diagrams/plantuml/ab-trial-boot.puml new file mode 100644 index 0000000..88edaca --- /dev/null +++ b/diagrams/plantuml/ab-trial-boot.puml @@ -0,0 +1,52 @@ +@startuml +title A/B trial boot and recovery +skinparam shadowing false +skinparam monochrome true + +state "A confirmed" as A +state "Prepare B\nA remains the normal boot choice" as Preparing +state "B prepared and verified" as Prepared +state "B trial pending\nSaved target and attempt limit" as Pending +state "B trial running\nUnconfirmed" as Trial +state "B confirmed" as Confirmed +state "Recovery to A pending" as Recovering +state "A recovery running" as Recovery +state "A recovery checked" as Recovered +state "Separate recovery required" as Failed + +[*] --> A +A --> Preparing : begin installation +Preparing --> Preparing : reset during preparation; boot A\nB is not eligible for trial +Preparing --> A : abandon incomplete or failed preparation +Preparing --> Prepared : installation and verification complete +Prepared --> Pending : save trial request through boot control +Pending --> Trial : reset [attempts left > 0]; save attempts left - 1\nbefore handing control to B +Trial --> Confirmed : local checks pass within time limit;\nsave confirmation +Trial --> Recovering : local checks fail; save recovery request +Trial --> Pending : reset before confirmation\n(power loss or covered watchdog) +Pending --> Recovering : reset; no attempts left +Recovering --> Recovery : boot control selects A on reset +Recovery --> Recovered : local recovery checks pass;\nsave outcome +Recovery --> Failed : recovery checks fail or startup fails\n(platform recovery policy) +Confirmed --> [*] +Recovered --> [*] +Failed --> [*] + +note right of Pending + Boot state is outside A and B. + Attempts are consumed before starting B. + Invalid records require a platform-defined + recovery path, never implicit confirmation. +end note + +note right of Recovery + A must still be compatible with shared data. + Do not endlessly alternate failed systems. +end note + +legend bottom + Preparation interruption assumes the installer preserves A and boot state. + State transitions assume valid durable records; torn writes need platform testing. + Checks and recovery are local; reports wait for connectivity. +endlegend +@enduml diff --git a/diagrams/plantuml/ota-architecture.puml b/diagrams/plantuml/ota-architecture.puml new file mode 100644 index 0000000..7966c82 --- /dev/null +++ b/diagrams/plantuml/ota-architecture.puml @@ -0,0 +1,38 @@ +@startuml +title OTA roles and device boundary +left to right direction +skinparam shadowing false +skinparam monochrome true +skinparam packageStyle rectangle + +package "Release and deployment services" { + component "Build, test and publish" as release + component "Artifact storage" as artifacts + component "Deployment management" as deployment +} + +package "Device" { + component "OTA manager" as manager + component "Installer" as installer + component "Boot control" as boot + component "Local health checks" as checks + database "Persistent update progress\nand pending reports" as progress +} + +release --> artifacts : publish artifact and\nverification information +release --> deployment : register release and compatibility +manager --> deployment +manager --> installer : authorize preparation +installer --> artifacts : download and verify +manager --> boot : request trial through\nplatform interface +checks --> boot : confirm after local checks pass\nor request recovery after failure +manager --> progress : save / resume progress;\nqueue reports while offline +manager --> checks : arrange checks after activation + +legend bottom + OTA manager -> deployment management: request releases; report running version and outcome. + Boot control runs before the OTA manager and handles exhausted trial attempts. + Hangs need a covered reset path. Checks and recovery do not require the server. + Roles may be combined in one tool. This view uses a system update with trial boot. +endlegend +@enduml diff --git a/diagrams/readme.md b/diagrams/readme.md new file mode 100644 index 0000000..3add3e5 --- /dev/null +++ b/diagrams/readme.md @@ -0,0 +1,17 @@ +# OTA diagrams + +PlantUML sources accompany chapters 03–05. The diagrams describe the illustrative A/B design, not a required platform or partition table. + +| View | Source | Preview | +|---|---|---| +| Roles and the server/device boundary | [PlantUML](plantuml/ota-architecture.puml) | [SVG](rendered/ota-architecture.svg) | +| Trial boot, confirmation and recovery | [PlantUML](plantuml/ab-trial-boot.puml) | [SVG](rendered/ab-trial-boot.svg) | +| Storage areas and access responsibilities | [PlantUML](plantuml/ab-storage-layout.puml) | [SVG](rendered/ab-storage-layout.svg) | + +From the repository root, regenerate previews with a local PlantUML installation and Graphviz: + +```sh +JAVA_TOOL_OPTIONS=-Djava.awt.headless=true plantuml -tsvg -o ../rendered diagrams/plantuml/*.puml +``` + +Keep sources and previews together when changing a diagram. No external rendering service is needed. State transitions assume valid durable records; these pictures do not establish power-loss safety. The [A/B simulation](../examples/simulations/ab-trial-boot/readme.md) explores the same logical transitions. diff --git a/diagrams/rendered/ab-storage-layout.svg b/diagrams/rendered/ab-storage-layout.svg new file mode 100644 index 0000000..3954b2c --- /dev/null +++ b/diagrams/rendered/ab-storage-layout.svg @@ -0,0 +1,79 @@ +A/B storage: logical areas and access responsibilitiesDevice storage (logical areas, not a partition table)Persistent device dataShared boot componentsSeparate update and recovery methodBoot stateNormal slot, trial target, attempts, confirmationSlot A: retained systemKernel + device tree + root filesystemSlot B: update targetKernel + device tree + root filesystemDownload and work areaDisposable preparation filesIdentity and calibrationRestricted accessConfiguration and application dataMust support the retained versionOTA progress, failure blocksand pending reportsInstallerBoot controlOTA manager /confirmation programRunning systemand applicationsreplace inactive slotuse temporary filesexecuteread / save before Linux startsselect matching componentsselect matching componentsrequest trial / confirmthrough platform interfaceread / saveread / writeauthorized access onlyA is running while B is prepared. Preserve A, boot state and device data.Persistent device data survives replacement of either system slot.Shared data is not rolled back by selecting A.Both system versions must understand the recovery records they read.Separate logical areas do not guarantee safe writes or whole-device recovery. \ No newline at end of file diff --git a/diagrams/rendered/ab-trial-boot.svg b/diagrams/rendered/ab-trial-boot.svg new file mode 100644 index 0000000..3ee0798 --- /dev/null +++ b/diagrams/rendered/ab-trial-boot.svg @@ -0,0 +1,81 @@ +A/B trial boot and recoveryA confirmedPrepare BA remains the normal boot choiceB prepared and verifiedB trial pendingSaved target and attempt limitB trial runningUnconfirmedB confirmedRecovery to A pendingA recovery runningA recovery checkedSeparate recovery requiredBoot state is outside A and B.Attempts are consumed before starting B.Invalid records require a platform-definedrecovery path, never implicit confirmation.A must still be compatible with shared data.Do not endlessly alternate failed systems.begin installationabandon incomplete or failed preparationreset during preparation; boot AB is not eligible for trialinstallation and verification completesave trial request through boot controlreset [attempts left > 0]; save attempts left - 1before handing control to Breset before confirmation(power loss or covered watchdog)local checks pass within time limit;save confirmationlocal checks fail; save recovery requestreset; no attempts leftboot control selects A on resetlocal recovery checks pass;save outcomerecovery checks fail or startup fails(platform recovery policy)Preparation interruption assumes the installer preserves A and boot state.State transitions assume valid durable records; torn writes need platform testing.Checks and recovery are local; reports wait for connectivity. \ No newline at end of file diff --git a/diagrams/rendered/ota-architecture.svg b/diagrams/rendered/ota-architecture.svg new file mode 100644 index 0000000..6316e52 --- /dev/null +++ b/diagrams/rendered/ota-architecture.svg @@ -0,0 +1,69 @@ +OTA roles and device boundaryRelease and deployment servicesDeviceBuild, test and publishArtifact storageDeployment managementOTA managerInstallerBoot controlLocal health checksPersistent update progressand pending reportspublish artifact andverification informationregister release and compatibilityauthorize preparationdownload and verifyrequest trial throughplatform interfaceconfirm after local checks passor request recovery after failuresave / resume progress;queue reports while offlinearrange checks after activationOTA manager -> deployment management: request releases; report running version and outcome.Boot control runs before the OTA manager and handles exhausted trial attempts.Hangs need a covered reset path. Checks and recovery do not require the server.Roles may be combined in one tool. This view uses a system update with trial boot. \ No newline at end of file diff --git a/docs/00-series-overview.md b/docs/00-series-overview.md index 7991051..d6714cc 100644 --- a/docs/00-series-overview.md +++ b/docs/00-series-overview.md @@ -71,7 +71,7 @@ The emphasis is always on understanding the underlying design rather than memori ## Repository Structure -The structure below shows the planned repository. The `diagrams/` and `examples/` directories will be added as the practical material develops. +The structure below includes the current documentation, PlantUML diagrams, and A/B simulation. Hardware demonstrations and later chapters will be added as the practical material develops. ```text ota-reference-design/ @@ -86,12 +86,12 @@ ota-reference-design/ │ ├── ... │ ├── diagrams/ -│ └── plantuml/ +│ ├── plantuml/ +│ └── rendered/ │ ├── examples/ -│ ├── raspberry-pi/ -│ ├── qemu/ │ └── simulations/ +│ └── ab-trial-boot/ │ ├── adr/ │ ├── ADR-001-repository-name.md @@ -196,6 +196,8 @@ Where possible, theoretical discussions are accompanied by practical material, i The objective is to connect high-level architecture with practical implementation. +Available now: [PlantUML diagrams and SVG previews](../diagrams/readme.md) and a [Python A/B trial boot simulation](../examples/simulations/ab-trial-boot/readme.md). Raspberry Pi and QEMU demonstrations are planned. + --- ## Intended Audience diff --git a/docs/03-system-architecture.md b/docs/03-system-architecture.md index d388999..6e36432 100644 --- a/docs/03-system-architecture.md +++ b/docs/03-system-architecture.md @@ -45,6 +45,12 @@ Alongside the update files, it needs a few details: which version this is, which These are roles, not a shopping list of separate services. One tool may cover several of them. What matters is knowing who handles each step. +The following view shows the roles and the server/device boundary for the system-update example used in the next chapter. + +![OTA roles and device boundary](../diagrams/rendered/ota-architecture.svg) + +[PlantUML source](../diagrams/plantuml/ota-architecture.puml) + ## 3. One Update, Start to Finish Imagine a device running version 1. Version 2 is ready. In this example, the device can prepare the new system while keeping the previous one available for recovery. diff --git a/docs/04-starting-an-updated-system-or-service.md b/docs/04-starting-an-updated-system-or-service.md index f23c4ef..759f6e1 100644 --- a/docs/04-starting-an-updated-system-or-service.md +++ b/docs/04-starting-an-updated-system-or-service.md @@ -99,6 +99,12 @@ Returning to a previous version is called **rollback**. Keeping A available make [ADR-014: Trial Boot and Confirmation for System Updates](../adr/ADR-014-trial-boot-and-confirmation.md) explains when to choose this policy and what the platform must support. +The state diagram separates an explicit health-check failure from a startup failure that prevents the checks from running. It also shows the checks after returning to A. + +![A/B trial boot and recovery](../diagrams/rendered/ab-trial-boot.svg) + +[PlantUML source](../diagrams/plantuml/ab-trial-boot.puml) · [Runnable simulation and failure scenarios](../examples/simulations/ab-trial-boot/readme.md) + ## 5. Example: Starting an Updated Service Now imagine a device with a stable system platform and an independently updated communication service. Version 1 works. Version 2 arrives as a package. diff --git a/docs/05-storage-layout.md b/docs/05-storage-layout.md index 1905afa..e5d9d59 100644 --- a/docs/05-storage-layout.md +++ b/docs/05-storage-layout.md @@ -64,6 +64,12 @@ The layout still has shared parts. If an update damages the shared boot componen Two slots on one storage device also do not protect against failure of that whole device. +The following diagram adds access responsibilities to the logical layout. A is running while the installer prepares B. Trial requests and confirmation use the platform's boot control interface. + +![A/B storage areas and access responsibilities](../diagrams/rendered/ab-storage-layout.svg) + +[PlantUML source](../diagrams/plantuml/ab-storage-layout.puml) · [Simulation of saved boot state across resets](../examples/simulations/ab-trial-boot/readme.md) + ## 3. What Lives Outside the System Slots? A system release provides common software. Device identity and calibration belong to an individual device. Installing a system image must not replace them with values from the release build. diff --git a/examples/simulations/ab-trial-boot/CMakeLists.txt b/examples/simulations/ab-trial-boot/CMakeLists.txt new file mode 100644 index 0000000..1f21b9a --- /dev/null +++ b/examples/simulations/ab-trial-boot/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.20) +project(ab_trial_boot LANGUAGES CXX) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(ab-simulation simulation.cpp) +target_compile_features(ab-simulation PUBLIC cxx_std_17) +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(ab-simulation PRIVATE -Wall -Wextra -Wpedantic) +endif() + +add_executable(ab-simulate main.cpp) +target_link_libraries(ab-simulate PRIVATE ab-simulation) + +include(CTest) +if(BUILD_TESTING) + add_executable(ab-simulation-tests tests.cpp) + target_link_libraries(ab-simulation-tests PRIVATE ab-simulation) + add_test(NAME recovery-invariants COMMAND ab-simulation-tests) + add_test(NAME all-scenarios COMMAND ab-simulate all) + add_test(NAME cli-help COMMAND ab-simulate --help) + add_test(NAME cli-invalid-scenario COMMAND ab-simulate invalid) + set_tests_properties(cli-invalid-scenario PROPERTIES WILL_FAIL TRUE) +endif() diff --git a/examples/simulations/ab-trial-boot/main.cpp b/examples/simulations/ab-trial-boot/main.cpp new file mode 100644 index 0000000..f746d83 --- /dev/null +++ b/examples/simulations/ab-trial-boot/main.cpp @@ -0,0 +1,30 @@ +#include "simulation.hpp" + +#include +#include +#include + +int main(int argc, char* argv[]) { + const std::string selected = argc > 1 ? argv[1] : "all"; + if (argc > 2 || selected == "--help" || selected == "-h" || + (selected != "all" && std::find(ota::scenarios().begin(), ota::scenarios().end(), + selected) == ota::scenarios().end())) { + std::ostream& output = (argc == 2 && (selected == "--help" || selected == "-h")) + ? std::cout : std::cerr; + output << "Usage: ab-simulate [all"; + for (const auto& scenario : ota::scenarios()) output << '|' << scenario; + output << "]\n"; + return &output == &std::cout ? 0 : 2; + } + try { + for (const auto& scenario : ota::scenarios()) { + if (selected != "all" && selected != scenario) continue; + std::cout << "\n=== " << scenario << " ===\n"; + ota::TemporaryDirectory directory; + ota::run_scenario(scenario, directory.path(), &std::cout); + } + } catch (const std::exception& error) { + std::cerr << "Simulation failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/examples/simulations/ab-trial-boot/readme.md b/examples/simulations/ab-trial-boot/readme.md new file mode 100644 index 0000000..b4cf04d --- /dev/null +++ b/examples/simulations/ab-trial-boot/readme.md @@ -0,0 +1,43 @@ +# A/B trial boot simulation + +This C++17 standard-library example follows the [trial boot diagram](../../../diagrams/plantuml/ab-trial-boot.puml), [chapter 04](../../../docs/04-starting-an-updated-system-or-service.md), and [ADR-014](../../../adr/ADR-014-trial-boot-and-confirmation.md). It illustrates boot decisions; it does not install software or access a device. No third-party libraries are required. + +Build and run from the repository root with CMake 3.20 or later and a C++17 compiler. These commands use a Linux temporary build directory to keep generated files outside the repository: + +```sh +cmake -S examples/simulations/ab-trial-boot -B /tmp/ota-ab-build -DCMAKE_BUILD_TYPE=Debug +cmake --build /tmp/ota-ab-build +/tmp/ota-ab-build/ab-simulate all +/tmp/ota-ab-build/ab-simulate attempts-exhausted +ctest --test-dir /tmp/ota-ab-build --output-on-failure +``` + +| Scenario | Expected result | +|---|---| +| `success` | Checks pass; B is confirmed and selected on the next boot. | +| `health-failure` | B fails checks; reset selects A; recovery checks pass. | +| `attempts-exhausted` | B never reaches checks; two unconfirmed attempts lead to A. | +| `restart-during-trial` | A reset preserves the consumed attempt; the second trial passes. | +| `interrupted-preparation` | Incomplete B remains ineligible; reset selects A. | +| `recovery-failure` | A also fails; stop at the boundary of a separate recovery policy. | + +With no argument, `ab-simulate` runs all scenarios. Use `--help` to list the accepted arguments. Invalid arguments return a nonzero exit status. + +The attempt limit of two is an illustrative choice. Each reset reconstructs the device from a versioned text record (`boot-state.txt`) in a temporary directory. The running slot is volatile. The saved normal slot stays A until B is confirmed. Boot control saves each consumed attempt before returning B as the selected system. A failed health check requests recovery immediately rather than spending the remaining trial attempts. + +The example keeps boot state outside the simulated slots, as shown in the [storage diagram](../../../diagrams/plantuml/ab-storage-layout.puml). Each scenario uses a separate temporary directory, removed when it finishes. The model covers one A-to-B update; it does not model subsequent releases. + +## Assumptions and limits + +Installation and artifact verification are represented by one preparation operation. Health checks are supplied boolean outcomes, including a timeout represented as failure. Resets are explicit calls; watchdog coverage and real startup timing are not simulated. Recovery assumes that A and shared data remain usable, unless the recovery-failure scenario supplies a failed check. + +The text file is a teaching stand-in for boot state, using standard C++ streams and `std::filesystem`. It records the phase, normal slot, optional trial slot, attempts remaining, slot readiness, and outcome. Replacement uses a temporary file and rename, but does not flush storage, implement redundant records, or recover invalid records. Unreadable records stop the simulation with an error; they are never treated as confirmation. This is not a power-loss-safe boot-state implementation. The simulation assumes completed valid writes; it does not test interruption within a write, coordination with OTA progress, data migration, signatures, bootloader integration, or hardware failure. These need platform-specific implementation and failure-injection tests. + +Outcomes remain local in the model. There is no server dependency or reporting transport. A real implementation must retain pending reports separately and send them when connectivity returns. + +## Source and checks + +- [simulation.hpp](simulation.hpp) and [simulation.cpp](simulation.cpp): typed state model, saved records, reset behavior, and scenarios. +- [main.cpp](main.cpp): command-line runner. +- [tests.cpp](tests.cpp): scenario outcomes and invariants for saved attempts, incomplete slots, confirmation, and unreadable state. Checks remain enabled in Release builds. +- [CMakeLists.txt](CMakeLists.txt): builds the runner and registers the invariant and CLI checks with CTest. diff --git a/examples/simulations/ab-trial-boot/simulation.cpp b/examples/simulations/ab-trial-boot/simulation.cpp new file mode 100644 index 0000000..c5b43c1 --- /dev/null +++ b/examples/simulations/ab-trial-boot/simulation.cpp @@ -0,0 +1,233 @@ +#include "simulation.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ota { +namespace { +constexpr const char* phase_names[] = { + "A confirmed", "Prepare B", "B prepared and verified", "B trial pending", + "B trial running", "B confirmed", "Recovery to A pending", + "A recovery running", "A recovery checked", "Separate recovery required" +}; + +Phase read_phase(const std::string& value) { + for (int i = 0; i < 10; ++i) { + if (value == phase_names[i]) return static_cast(i); + } + throw std::runtime_error("Invalid saved phase"); +} + +Slot read_slot(const std::string& value) { + if (value == "A") return Slot::A; + if (value == "B") return Slot::B; + throw std::runtime_error("Invalid saved slot"); +} + +void require(bool condition, const char* message) { + if (!condition) throw std::logic_error(message); +} +} // namespace + +const char* name(Phase phase) { return phase_names[static_cast(phase)]; } +const char* name(Slot slot) { return slot == Slot::A ? "A" : "B"; } + +Device::Device(const std::filesystem::path& directory) + : path_(directory / "boot-state.txt") { + if (!std::filesystem::exists(path_)) { + save(); + return; + } + std::ifstream input(path_); + std::string header, phase, normal, trial; + int a_ready = 0, b_ready = 0; + if (!(input >> header >> std::quoted(phase) >> normal >> trial + >> state_.attempts_left >> a_ready >> b_ready + >> std::quoted(state_.outcome)) || header != "OTA_AB_STATE_V1") { + throw std::runtime_error("Unreadable saved boot state"); + } + input >> std::ws; + if (!input.eof() || state_.attempts_left < 0 || + (a_ready != 0 && a_ready != 1) || (b_ready != 0 && b_ready != 1)) { + throw std::runtime_error("Invalid saved boot state"); + } + state_.phase = read_phase(phase); + state_.normal = read_slot(normal); + if (trial != "none") state_.trial = read_slot(trial); + state_.a_ready = a_ready != 0; + state_.b_ready = b_ready != 0; +} + +void Device::save() const { + auto temporary = path_; + temporary += ".tmp"; + std::ofstream output(temporary, std::ios::trunc); + output << "OTA_AB_STATE_V1\n" << std::quoted(name(state_.phase)) << '\n' + << name(state_.normal) << '\n' + << (state_.trial ? name(*state_.trial) : "none") << '\n' + << state_.attempts_left << '\n' + << state_.a_ready << ' ' << state_.b_ready << '\n' + << std::quoted(state_.outcome) << '\n'; + output.close(); + if (!output) throw std::runtime_error("Cannot save boot state"); + // Teaching stand-in: rename alone does not guarantee power-loss durability. + std::filesystem::rename(temporary, path_); +} + +void Device::prepare(bool complete) { + require(state_.phase == Phase::AConfirmed || state_.phase == Phase::PreparingB, + "Cannot prepare B in this phase"); + state_.b_ready = false; + state_.phase = Phase::PreparingB; + save(); + if (complete) { + // Represents complete installation AND artifact verification. + state_.b_ready = true; + state_.phase = Phase::BPrepared; + save(); + } +} + +void Device::request_trial(int attempts) { + require(state_.phase == Phase::BPrepared && state_.b_ready, + "B must be prepared and verified before trial"); + require(attempts > 0, "The trial needs at least one attempt"); + state_.phase = Phase::TrialPending; + state_.trial = Slot::B; + state_.attempts_left = attempts; + save(); +} + +Slot Device::boot() { + require(state_.phase != Phase::SeparateRecoveryRequired, + "Separate recovery is required; do not alternate failed systems"); + if (state_.trial) { + if (state_.attempts_left > 0) { + --state_.attempts_left; + state_.phase = Phase::TrialRunning; + // Persist the consumed attempt BEFORE handing control to B. + save(); + running_ = *state_.trial; + return *running_; + } + state_.phase = Phase::RecoveryPending; + state_.trial.reset(); + save(); + } + if (state_.phase == Phase::RecoveryPending) { + state_.phase = Phase::RecoveryRunning; + save(); + } + running_ = state_.normal; + return *running_; +} + +void Device::check(bool healthy) { + require(running_.has_value(), "Boot before checking the running system"); + require(state_.phase == Phase::TrialRunning || state_.phase == Phase::RecoveryRunning, + "Checks require a running trial or recovery"); + if (state_.phase == Phase::TrialRunning) { + state_.trial.reset(); + state_.attempts_left = 0; + if (healthy) { + state_.phase = Phase::BConfirmed; + state_.normal = Slot::B; + state_.outcome = "B confirmed"; + } else { + state_.phase = Phase::RecoveryPending; + } + } else if (healthy) { + state_.phase = Phase::RecoveryChecked; + state_.outcome = "Recovered to A"; + } else { + state_.phase = Phase::SeparateRecoveryRequired; + state_.outcome = "Recovery A failed"; + } + save(); +} + +TemporaryDirectory::TemporaryDirectory() { + std::random_device random; + for (int attempt = 0; attempt < 100; ++attempt) { + auto candidate = std::filesystem::temp_directory_path() / + ("ota-ab-simulation-" + std::to_string(random()) + "-" + std::to_string(random())); + if (std::filesystem::create_directory(candidate)) { + path_ = std::move(candidate); + return; + } + } + throw std::runtime_error("Cannot create a unique temporary directory"); +} + +TemporaryDirectory::~TemporaryDirectory() { + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); +} + +const std::vector& scenarios() { + static const std::vector names = { + "success", "health-failure", "attempts-exhausted", "restart-during-trial", + "interrupted-preparation", "recovery-failure" + }; + return names; +} + +Device run_scenario(const std::string& scenario, const std::filesystem::path& directory, + std::ostream* output) { + if (std::find(scenarios().begin(), scenarios().end(), scenario) == scenarios().end()) { + throw std::invalid_argument("Unknown scenario: " + scenario); + } + Device device(directory); + auto show = [&](const std::string& event) { + if (!output) return; + const auto& state = device.state(); + *output << event << ": " << name(state.phase) << "; normal=" << name(state.normal) + << "; attempts_left=" << state.attempts_left << "; running=" + << (device.running() ? name(*device.running()) : "none") << '\n'; + }; + auto reset = [&] { + device = Device(directory); + const auto selected = device.boot(); + show(std::string("Reset -> ") + name(selected)); + }; + auto message = [&](const char* text) { if (output) *output << text << '\n'; }; + + show("Initial state"); + if (scenario == "interrupted-preparation") { + device.prepare(false); + show("Interrupted installation: B remains ineligible"); + reset(); + return device; + } + device.prepare(); + show("Installation and verification complete"); + device.request_trial(2); + show("Trial requested"); + reset(); + if (scenario == "attempts-exhausted" || scenario == "restart-during-trial") { + message("No confirmation: simulate a reset caused by power loss or watchdog"); + reset(); + } + if (scenario == "attempts-exhausted") { + message("B still cannot reach health checks; simulate another reset"); + reset(); + device.check(true); + show("Recovery checks pass"); + } else if (scenario == "health-failure" || scenario == "recovery-failure") { + device.check(false); + show("B health checks fail"); + reset(); + device.check(scenario != "recovery-failure"); + show("Recovery checks complete"); + } else { + device.check(true); + show("B health checks pass; confirmation saved"); + reset(); + } + return device; +} +} // namespace ota diff --git a/examples/simulations/ab-trial-boot/simulation.hpp b/examples/simulations/ab-trial-boot/simulation.hpp new file mode 100644 index 0000000..d58b00e --- /dev/null +++ b/examples/simulations/ab-trial-boot/simulation.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ota { + +enum class Phase { + AConfirmed, PreparingB, BPrepared, TrialPending, TrialRunning, + BConfirmed, RecoveryPending, RecoveryRunning, RecoveryChecked, + SeparateRecoveryRequired +}; + +enum class Slot { A, B }; + +const char* name(Phase phase); +const char* name(Slot slot); + +struct State { + Phase phase = Phase::AConfirmed; + Slot normal = Slot::A; + std::optional trial; + int attempts_left = 0; + bool a_ready = true; + bool b_ready = false; + std::string outcome; +}; + +class Device { +public: + explicit Device(const std::filesystem::path& directory); + const State& state() const { return state_; } + std::optional running() const { return running_; } + void prepare(bool complete = true); + void request_trial(int attempts = 2); + Slot boot(); + void check(bool healthy); + +private: + void save() const; + State state_; + std::filesystem::path path_; + // Volatile: reconstructing a Device requires another boot before checks. + std::optional running_; +}; + +// Own only a freshly created directory and clean it up at scope exit. +class TemporaryDirectory { +public: + TemporaryDirectory(); + ~TemporaryDirectory(); + TemporaryDirectory(const TemporaryDirectory&) = delete; + TemporaryDirectory& operator=(const TemporaryDirectory&) = delete; + const std::filesystem::path& path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +const std::vector& scenarios(); +Device run_scenario(const std::string& scenario, + const std::filesystem::path& directory, + std::ostream* output = nullptr); + +} // namespace ota diff --git a/examples/simulations/ab-trial-boot/tests.cpp b/examples/simulations/ab-trial-boot/tests.cpp new file mode 100644 index 0000000..4416661 --- /dev/null +++ b/examples/simulations/ab-trial-boot/tests.cpp @@ -0,0 +1,124 @@ +#include "simulation.hpp" + +#include +#include +#include +#include + +namespace { +void expect(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +template +void expect_rejected(Operation operation) { + try { + operation(); + } catch (const std::logic_error&) { + return; + } + throw std::runtime_error("Invalid operation was accepted"); +} + +void scenario_outcomes() { + struct Expected { + const char* scenario; + ota::Phase phase; + ota::Slot normal; + const char* outcome; + }; + const Expected expected[] = { + {"success", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"}, + {"health-failure", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"}, + {"attempts-exhausted", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"}, + {"restart-during-trial", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"}, + {"interrupted-preparation", ota::Phase::PreparingB, ota::Slot::A, ""}, + {"recovery-failure", ota::Phase::SeparateRecoveryRequired, ota::Slot::A, + "Recovery A failed"} + }; + for (const auto& result : expected) { + ota::TemporaryDirectory directory; + auto device = ota::run_scenario(result.scenario, directory.path()); + const ota::Device restored(directory.path()); + const auto& state = restored.state(); + expect(state.phase == result.phase, "Unexpected saved phase"); + expect(state.normal == result.normal, "Unexpected normal slot"); + expect(state.outcome == result.outcome, "Unexpected saved outcome"); + expect(state.a_ready, "The retained slot was modified"); + if (state.phase == ota::Phase::SeparateRecoveryRequired) { + expect_rejected([&] { device.boot(); }); + } + } +} + +void saved_attempts() { + ota::TemporaryDirectory directory; + ota::Device device(directory.path()); + device.prepare(); + device.request_trial(2); + for (int left : {1, 0}) { + device = ota::Device(directory.path()); + expect(device.boot() == ota::Slot::B, "Expected trial boot of B"); + const ota::Device restored(directory.path()); + expect(restored.state().attempts_left == left, "Attempt was not saved before boot"); + expect(restored.state().normal == ota::Slot::A, "B was confirmed prematurely"); + } + device = ota::Device(directory.path()); + expect(device.boot() == ota::Slot::A, "Exhausted trial did not return to A"); + expect(device.state().phase == ota::Phase::RecoveryRunning, "Recovery checks skipped"); +} + +void incomplete_slot() { + ota::TemporaryDirectory directory; + ota::Device device(directory.path()); + device.prepare(false); + device = ota::Device(directory.path()); + expect(!device.state().b_ready, "Incomplete B was marked ready"); + expect_rejected([&] { device.request_trial(); }); + expect(device.boot() == ota::Slot::A, "Incomplete B was selected"); +} + +void confirmation_requires_boot() { + ota::TemporaryDirectory directory; + ota::Device device(directory.path()); + device.prepare(); + expect_rejected([&] { device.request_trial(0); }); + expect_rejected([&] { device.request_trial(-1); }); + device.request_trial(); + expect_rejected([&] { device.check(true); }); + device.boot(); + ota::Device restored(directory.path()); + expect(!restored.running(), "Running slot survived reconstruction"); + expect_rejected([&] { restored.check(true); }); + device.check(true); + expect_rejected([&] { device.check(false); }); +} + +void unreadable_state() { + ota::TemporaryDirectory directory; + { + std::ofstream output(directory.path() / "boot-state.txt"); + output << "incomplete record\n"; + } + try { + const ota::Device device(directory.path()); + } catch (const std::runtime_error&) { + return; + } + throw std::runtime_error("Unreadable state was accepted"); +} +} // namespace + +int main() { + try { + scenario_outcomes(); + saved_attempts(); + incomplete_slot(); + confirmation_requires_boot(); + unreadable_state(); + std::cout << "All recovery invariant checks passed\n"; + } catch (const std::exception& error) { + std::cerr << "Test failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/readme.md b/readme.md index b5025e8..47907ba 100644 --- a/readme.md +++ b/readme.md @@ -22,6 +22,8 @@ This repository is not intended to be a production-ready framework. Instead, it The repository is being developed incrementally as a structured series of articles, diagrams, architecture decisions, and practical examples. +Start with the [series overview](docs/00-series-overview.md). The current practical material includes [PlantUML diagrams](diagrams/readme.md) and an [A/B trial boot simulation](examples/simulations/ab-trial-boot/readme.md) with startup and recovery failure scenarios. + ## License