06 - atomic update
This commit is contained in:
1 parent
6d63457737
commit
2bdacaf37a
21 files changed
+1302
-365
No files matched your search
@@ -0,0 +1,23 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(interrupted_installation LANGUAGES CXX)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
add_library(installation-model simulation.cpp)
|
||||
target_compile_features(installation-model PUBLIC cxx_std_17)
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(installation-model PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
add_executable(installation-simulate main.cpp)
|
||||
target_link_libraries(installation-simulate PRIVATE installation-model)
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(installation-tests tests.cpp)
|
||||
target_link_libraries(installation-tests PRIVATE installation-model)
|
||||
add_test(NAME installation-invariants COMMAND installation-tests)
|
||||
add_test(NAME all-scenarios COMMAND installation-simulate all)
|
||||
add_test(NAME cli-help COMMAND installation-simulate --help)
|
||||
add_test(NAME cli-invalid COMMAND installation-simulate invalid)
|
||||
set_tests_properties(cli-invalid PROPERTIES WILL_FAIL TRUE)
|
||||
endif()
|
||||
@@ -0,0 +1,49 @@
|
||||
#include "simulation.hpp"
|
||||
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace model = ota::installation;
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc == 2 && std::string(argv[1]) == "--help") {
|
||||
std::cout << "Usage: installation-simulate [all|--help]\n";
|
||||
return 0;
|
||||
}
|
||||
if (argc > 2 || (argc == 2 && std::string(argv[1]) != "all")) {
|
||||
std::cerr << "Usage: installation-simulate [all|--help]\n";
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
for (int cut = 0; cut <= model::installation_steps; ++cut) {
|
||||
model::Storage storage;
|
||||
{
|
||||
model::Installer installer(storage);
|
||||
for (int step = 0; step < cut; ++step) model::perform_step(installer, step);
|
||||
}
|
||||
model::Installer restarted(storage);
|
||||
const auto action = restarted.recover();
|
||||
std::cout << "Reset after " << cut << " steps: boot=" << model::boot_choice(storage.boot)
|
||||
<< "; " << model::name(action) << '\n';
|
||||
}
|
||||
for (const auto result: {model::WriteResult::OldRecord, model::WriteResult::NewRecord, model::WriteResult::InvalidRecord}) {
|
||||
model::Storage storage;
|
||||
{
|
||||
model::Installer installer(storage);
|
||||
for (int step = 0; step < 5; ++step) model::perform_step(installer, step);
|
||||
try {
|
||||
installer.request_trial(result);
|
||||
} catch (const std::runtime_error &error) {
|
||||
std::cout << error.what() << '\n';
|
||||
}
|
||||
}
|
||||
model::Installer restarted(storage);
|
||||
std::cout << "Boot-state write outcome: " << model::name(restarted.recover()) << '\n';
|
||||
}
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Interrupted A/B installation model
|
||||
|
||||
This C++17 example follows [chapter 06](../../../docs/06-atomic-updates.md), [ADR-018](../../../adr/ADR-018-prepare-and-verify-before-activation.md), and [ADR-019](../../../adr/ADR-019-recover-interrupted-updates-from-durable-state.md). It models preparation and activation of B while confirmed system A remains available. It does not access devices or install files.
|
||||
|
||||
Build and run from the repository root with CMake 3.20 or later and a C++17 compiler. No third-party libraries are required.
|
||||
|
||||
```sh
|
||||
cmake -S examples/simulations/interrupted-installation -B /tmp/ota-installation-build -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build /tmp/ota-installation-build
|
||||
/tmp/ota-installation-build/installation-simulate all
|
||||
ctest --test-dir /tmp/ota-installation-build --output-on-failure
|
||||
```
|
||||
|
||||
With no argument, the runner also runs all scenarios. `--help` prints usage. Unknown arguments return a nonzero exit status.
|
||||
|
||||
## Reset Points
|
||||
|
||||
Each run starts from fresh storage. It performs the first N steps, destroys the installer, and creates another installer using the same simulated storage.
|
||||
|
||||
| Steps completed before reset | Last operation | Recovery action |
|
||||
|---|---|---|
|
||||
| 0 | None | Keep A; start preparation. |
|
||||
| 1 | Save release and target intent | Keep A; restart preparation. |
|
||||
| 2 | Write part of B | Keep A; restart preparation. |
|
||||
| 3 | Complete writing B | Verify again; save prepared state; wait for activation policy. |
|
||||
| 4 | Verify B | Repeat verification; save prepared state; wait for activation policy. |
|
||||
| 5 | Save prepared state | Verify again; wait for activation policy. |
|
||||
| 6 | Save trial request | Preserve trial and attempts; repair old progress. |
|
||||
| 7 | Save trial-requested progress | Preserve trial and attempts. |
|
||||
|
||||
The runner also supplies three possible results of an interrupted boot-record write: retain the old record, retain the new record, or find no valid record. The last case requires separate recovery. These are assumed outcomes, not a redundant-record algorithm.
|
||||
|
||||
## Storage and Ownership
|
||||
|
||||
`Storage` represents persistent bytes and records in memory. Each modeled storage operation is already complete and durable when it returns. `Installer` holds a temporary verification result. Recreating it clears that result, so a new activation requires fresh verification.
|
||||
|
||||
Boot state owns selection and the trial limit. OTA progress can be old. When a trial request already exists, recovery repairs progress without rewriting B or renewing attempts. Recovery with A selected checks both the release identity and installed contents before accepting B as prepared.
|
||||
|
||||
Only one installer is active. A is initially confirmed; there is no earlier pending trial. B is writable only while boot state selects A. A remains unchanged. `recover()` does not activate B automatically; the caller still owns activation policy.
|
||||
|
||||
## Checks and Limits
|
||||
|
||||
[tests.cpp](tests.cpp) checks all reset points and their continuation, activation guards, loss of temporary verification, damaged contents, wrong release identity, all supplied boot-write outcomes, and repeated recovery without renewing attempts. Checks remain active in Release builds.
|
||||
|
||||
The model compares strings with fixed expected bytes. It has no signatures, real files, flushing, redundant records, or interruption inside a write. It does not model storage wear, concurrent installers, data migration, running processes, or later releases. Direct changes to `Storage` in tests represent faults between installer instances.
|
||||
|
||||
The boot record is simplified to A selected, B trial, or invalid. Attempts are recorded but not consumed here. The printed boot choice is the next lifecycle destination, not a real bootloader decision. Confirmation, attempt exhaustion, and recovery health checks remain in the separate [A/B trial boot example](../ab-trial-boot/readme.md); the programs are not integrated.
|
||||
|
||||
Real storage needs a defined durability contract and failure-injection tests on the target platform. This model checks ordering and recovery decisions only.
|
||||
|
||||
## Sources and Diagrams
|
||||
|
||||
- [simulation.hpp](simulation.hpp) and [simulation.cpp](simulation.cpp): storage, installer, and recovery rules.
|
||||
- [main.cpp](main.cpp): scenario output.
|
||||
- [CMakeLists.txt](CMakeLists.txt): build and CTest integration.
|
||||
- [Preparation and activation sequence](../../../diagrams/plantuml/ab-installation-sequence.puml).
|
||||
- [Installation recovery states](../../../diagrams/plantuml/ab-installation-recovery.puml).
|
||||
|
||||
Format C++ sources with the repository's root `.clang-format`.
|
||||
@@ -0,0 +1,154 @@
|
||||
#include "simulation.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
namespace ota::installation {
|
||||
|
||||
const std::string &expected_contents() {
|
||||
static const std::string contents = "release-2:kernel+device-tree+rootfs";
|
||||
return contents;
|
||||
}
|
||||
|
||||
const char *name(RecoveryAction action) {
|
||||
switch (action) {
|
||||
case RecoveryAction::RestartPreparation:
|
||||
return "restart preparation; keep A";
|
||||
case RecoveryAction::ReadyToActivate:
|
||||
return "B verified again; wait for activation policy";
|
||||
case RecoveryAction::PreserveTrial:
|
||||
return "preserve saved B trial and attempts";
|
||||
case RecoveryAction::SeparateRecovery:
|
||||
return "stop; separate recovery required";
|
||||
}
|
||||
throw std::logic_error("Unknown recovery action");
|
||||
}
|
||||
|
||||
const char *boot_choice(BootRecord record) {
|
||||
switch (record) {
|
||||
case BootRecord::ASelected:
|
||||
return "A";
|
||||
case BootRecord::BTrial:
|
||||
return "B trial (attempt handling is outside this model)";
|
||||
case BootRecord::Invalid:
|
||||
return "separate recovery";
|
||||
}
|
||||
throw std::logic_error("Unknown boot record");
|
||||
}
|
||||
|
||||
void Installer::require_writable() const {
|
||||
if (storage_.boot != BootRecord::ASelected) {
|
||||
throw std::logic_error("B is selected or boot state is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
void Installer::begin() {
|
||||
require_writable();
|
||||
verified_ = false;
|
||||
storage_.release = "release-2";
|
||||
storage_.progress = Progress::Writing;
|
||||
}
|
||||
|
||||
void Installer::write_partial() {
|
||||
require_writable();
|
||||
if (storage_.progress != Progress::Writing || storage_.release != "release-2") {
|
||||
throw std::logic_error("Save matching intent before writing");
|
||||
}
|
||||
verified_ = false;
|
||||
storage_.b_contents = expected_contents().substr(0, 8);
|
||||
}
|
||||
|
||||
void Installer::finish_write() {
|
||||
require_writable();
|
||||
if (storage_.progress != Progress::Writing || storage_.release != "release-2") {
|
||||
throw std::logic_error("Save matching intent before writing");
|
||||
}
|
||||
verified_ = false;
|
||||
storage_.b_contents = expected_contents();
|
||||
}
|
||||
|
||||
void Installer::verify() {
|
||||
require_writable();
|
||||
verified_ = false;
|
||||
if (storage_.release != "release-2" || storage_.b_contents != expected_contents()) {
|
||||
throw std::runtime_error("Installed release does not match expected contents");
|
||||
}
|
||||
verified_ = true;
|
||||
}
|
||||
|
||||
void Installer::save_prepared() {
|
||||
require_writable();
|
||||
if (!verified_) throw std::logic_error("Verify B before saving prepared state");
|
||||
storage_.progress = Progress::Prepared;
|
||||
}
|
||||
|
||||
void Installer::request_trial(WriteResult result) {
|
||||
require_writable();
|
||||
if (!verified_ || storage_.progress != Progress::Prepared) {
|
||||
throw std::logic_error("Verify B and save prepared state before activation");
|
||||
}
|
||||
// Supplied outcomes stand for the storage contract of boot control.
|
||||
// OldRecord leaves all fields intact; NewRecord saves one complete record.
|
||||
if (result == WriteResult::OldRecord) {
|
||||
throw std::runtime_error("Interrupted request; old record remains");
|
||||
}
|
||||
if (result == WriteResult::InvalidRecord) {
|
||||
storage_.boot = BootRecord::Invalid;
|
||||
throw std::runtime_error("Interrupted request; no valid record remains");
|
||||
}
|
||||
storage_.boot = BootRecord::BTrial;
|
||||
storage_.attempts_left = 2;
|
||||
}
|
||||
|
||||
void Installer::save_requested() {
|
||||
if (storage_.boot != BootRecord::BTrial) {
|
||||
throw std::logic_error("No saved B trial request");
|
||||
}
|
||||
storage_.progress = Progress::TrialRequested;
|
||||
}
|
||||
|
||||
RecoveryAction Installer::recover() {
|
||||
verified_ = false;
|
||||
// Boot state takes priority over progress. Never write B or renew attempts
|
||||
// when a trial is already selected. Invalid boot state is not confirmation.
|
||||
if (storage_.boot == BootRecord::Invalid) return RecoveryAction::SeparateRecovery;
|
||||
if (storage_.boot == BootRecord::BTrial) {
|
||||
save_requested();
|
||||
return RecoveryAction::PreserveTrial;
|
||||
}
|
||||
if (storage_.release != "release-2" || storage_.b_contents != expected_contents()) {
|
||||
return RecoveryAction::RestartPreparation;
|
||||
}
|
||||
verify();
|
||||
save_prepared();
|
||||
return RecoveryAction::ReadyToActivate;
|
||||
}
|
||||
|
||||
void perform_step(Installer &installer, int step) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
installer.begin();
|
||||
break;
|
||||
case 1:
|
||||
installer.write_partial();
|
||||
break;
|
||||
case 2:
|
||||
installer.finish_write();
|
||||
break;
|
||||
case 3:
|
||||
installer.verify();
|
||||
break;
|
||||
case 4:
|
||||
installer.save_prepared();
|
||||
break;
|
||||
case 5:
|
||||
installer.request_trial();
|
||||
break;
|
||||
case 6:
|
||||
installer.save_requested();
|
||||
break;
|
||||
default:
|
||||
throw std::logic_error("Unknown installation step");
|
||||
}
|
||||
}
|
||||
|
||||
}// namespace ota::installation
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ota::installation {
|
||||
|
||||
enum class Progress { Idle,
|
||||
Writing,
|
||||
Prepared,
|
||||
TrialRequested };
|
||||
enum class BootRecord { ASelected,
|
||||
BTrial,
|
||||
Invalid };
|
||||
enum class WriteResult { OldRecord,
|
||||
NewRecord,
|
||||
InvalidRecord };
|
||||
enum class RecoveryAction { RestartPreparation,
|
||||
ReadyToActivate,
|
||||
PreserveTrial,
|
||||
SeparateRecovery };
|
||||
|
||||
// This object stands for persistent storage. Each assignment is a completed
|
||||
// durable operation in the model, not a real storage implementation.
|
||||
struct Storage {
|
||||
std::string a_contents = "release-1";
|
||||
std::string b_contents;
|
||||
std::string release;
|
||||
Progress progress = Progress::Idle;
|
||||
BootRecord boot = BootRecord::ASelected;
|
||||
int attempts_left = 0;
|
||||
};
|
||||
|
||||
const std::string &expected_contents();
|
||||
const char *name(RecoveryAction action);
|
||||
const char *boot_choice(BootRecord record);
|
||||
|
||||
class Installer {
|
||||
public:
|
||||
explicit Installer(Storage &storage) : storage_(storage) {}
|
||||
void begin();
|
||||
void write_partial();
|
||||
void finish_write();
|
||||
void verify();
|
||||
void save_prepared();
|
||||
void request_trial(WriteResult result = WriteResult::NewRecord);
|
||||
void save_requested();
|
||||
RecoveryAction recover();
|
||||
|
||||
private:
|
||||
void require_writable() const;
|
||||
Storage &storage_;
|
||||
// Lost when the installer is reconstructed after a simulated reset.
|
||||
bool verified_ = false;
|
||||
};
|
||||
|
||||
// Ordered operations; cut points are before step 0 and after each step.
|
||||
constexpr int installation_steps = 7;
|
||||
void perform_step(Installer &installer, int step);
|
||||
|
||||
}// namespace ota::installation
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "simulation.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace model = ota::installation;
|
||||
|
||||
namespace {
|
||||
|
||||
void expect(bool condition, const char *message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
template<class Operation>
|
||||
void expect_rejected(Operation operation) {
|
||||
try {
|
||||
operation();
|
||||
} catch (const std::logic_error &) {
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("Unsafe operation accepted");
|
||||
}
|
||||
|
||||
void cut_points() {
|
||||
for (int cut = 0; cut <= model::installation_steps; ++cut) {
|
||||
model::Storage storage;
|
||||
{
|
||||
model::Installer installer(storage);
|
||||
for (int step = 0; step < cut; ++step) model::perform_step(installer, step);
|
||||
}
|
||||
model::Installer restarted(storage);
|
||||
const auto action = restarted.recover();
|
||||
expect(storage.a_contents == "release-1", "A changed during installation");
|
||||
if (cut < 3) {
|
||||
expect(action == model::RecoveryAction::RestartPreparation, "Incomplete B accepted");
|
||||
expect(storage.boot == model::BootRecord::ASelected, "Incomplete B selected");
|
||||
expect_rejected([&] { restarted.request_trial(); });
|
||||
// Exercise the documented restart path, not only its decision.
|
||||
for (int step = 0; step < model::installation_steps; ++step) model::perform_step(restarted, step);
|
||||
} else if (cut < 6) {
|
||||
expect(action == model::RecoveryAction::ReadyToActivate, "Complete B not reverified");
|
||||
expect(storage.boot == model::BootRecord::ASelected, "Recovery activated B without policy");
|
||||
restarted.request_trial();
|
||||
restarted.save_requested();
|
||||
} else {
|
||||
expect(action == model::RecoveryAction::PreserveTrial, "Saved trial lost");
|
||||
expect(storage.progress == model::Progress::TrialRequested, "Old progress not repaired");
|
||||
}
|
||||
expect(storage.boot == model::BootRecord::BTrial, "Continuation did not reach trial");
|
||||
expect(storage.b_contents == model::expected_contents(), "Selected B is incomplete");
|
||||
expect(storage.a_contents == "release-1", "Continuation changed A");
|
||||
storage.attempts_left = 1;// Boot control has already consumed an attempt.
|
||||
model::Installer second_restart(storage);
|
||||
expect(second_restart.recover() == model::RecoveryAction::PreserveTrial, "Repeated recovery lost trial");
|
||||
expect(storage.attempts_left == 1, "Recovery renewed trial attempts");
|
||||
expect_rejected([&] { second_restart.begin(); });
|
||||
expect_rejected([&] { second_restart.write_partial(); });
|
||||
expect_rejected([&] { second_restart.finish_write(); });
|
||||
expect_rejected([&] { second_restart.request_trial(); });
|
||||
}
|
||||
}
|
||||
|
||||
void activation_guards() {
|
||||
model::Storage storage;
|
||||
model::Installer installer(storage);
|
||||
expect_rejected([&] { installer.finish_write(); });
|
||||
expect_rejected([&] { installer.save_requested(); });
|
||||
installer.begin();
|
||||
installer.finish_write();
|
||||
expect_rejected([&] { installer.save_prepared(); });
|
||||
installer.verify();
|
||||
expect_rejected([&] { installer.request_trial(); });
|
||||
installer.save_prepared();
|
||||
model::Installer restarted(storage);
|
||||
expect_rejected([&] { restarted.request_trial(); });
|
||||
expect(restarted.recover() == model::RecoveryAction::ReadyToActivate, "Fresh verification failed");
|
||||
restarted.request_trial();
|
||||
}
|
||||
|
||||
void bad_contents_and_identity() {
|
||||
for (bool wrong_release: {false, true}) {
|
||||
model::Storage storage;
|
||||
{
|
||||
model::Installer installer(storage);
|
||||
for (int step = 0; step < 5; ++step) model::perform_step(installer, step);
|
||||
}
|
||||
if (wrong_release) storage.release = "another-release";
|
||||
else
|
||||
storage.b_contents = "damaged";
|
||||
model::Installer restarted(storage);
|
||||
expect(restarted.recover() == model::RecoveryAction::RestartPreparation, "Stale prepared record trusted");
|
||||
try {
|
||||
restarted.verify();
|
||||
throw std::logic_error("Bad release passed verification");
|
||||
} catch (const std::runtime_error &) {
|
||||
// Expected content or identity failure; logic_error escapes.
|
||||
}
|
||||
expect_rejected([&] { restarted.request_trial(); });
|
||||
expect(storage.boot == model::BootRecord::ASelected, "Bad release selected");
|
||||
}
|
||||
}
|
||||
|
||||
void interrupted_boot_write() {
|
||||
for (const auto result: {model::WriteResult::OldRecord, model::WriteResult::NewRecord, model::WriteResult::InvalidRecord}) {
|
||||
model::Storage storage;
|
||||
{
|
||||
model::Installer installer(storage);
|
||||
for (int step = 0; step < 5; ++step) model::perform_step(installer, step);
|
||||
bool failed = false;
|
||||
try {
|
||||
installer.request_trial(result);
|
||||
} catch (const std::runtime_error &) {
|
||||
failed = true;
|
||||
}
|
||||
expect(failed == (result != model::WriteResult::NewRecord), "Wrong request result");
|
||||
}
|
||||
model::Installer restarted(storage);
|
||||
const auto action = restarted.recover();
|
||||
if (result == model::WriteResult::OldRecord) {
|
||||
expect(action == model::RecoveryAction::ReadyToActivate, "Old boot record not retained");
|
||||
expect(storage.attempts_left == 0, "Old record partly changed");
|
||||
} else if (result == model::WriteResult::NewRecord) {
|
||||
expect(action == model::RecoveryAction::PreserveTrial, "New boot record not retained");
|
||||
expect(storage.attempts_left == 2, "New trial limit lost");
|
||||
} else {
|
||||
expect(action == model::RecoveryAction::SeparateRecovery, "Invalid boot state accepted");
|
||||
expect(storage.progress == model::Progress::Prepared, "Invalid boot state changed progress");
|
||||
expect_rejected([&] { restarted.begin(); });
|
||||
expect_rejected([&] { restarted.request_trial(); });
|
||||
}
|
||||
expect(storage.a_contents == "release-1", "Boot record failure changed A");
|
||||
}
|
||||
}
|
||||
|
||||
}// namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
cut_points();
|
||||
activation_guards();
|
||||
bad_contents_and_identity();
|
||||
interrupted_boot_write();
|
||||
std::cout << "All installation recovery checks passed\n";
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << "Test failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user