diagrams and simulation

This commit is contained in:
deeaitch committed 2026-10-11 07:21:11 -04:00
1 parent bf9871eb9e
commit d65910a7c5
18 files changed
+926 -4

No files matched your search

@@ -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()
@@ -0,0 +1,30 @@
#include "simulation.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
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;
}
}
@@ -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.
@@ -0,0 +1,233 @@
#include "simulation.hpp"
#include <algorithm>
#include <fstream>
#include <iomanip>
#include <random>
#include <stdexcept>
#include <utility>
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<Phase>(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<int>(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<std::string>& scenarios() {
static const std::vector<std::string> 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
@@ -0,0 +1,68 @@
#pragma once
#include <filesystem>
#include <optional>
#include <ostream>
#include <string>
#include <vector>
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<Slot> 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<Slot> 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<Slot> 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<std::string>& scenarios();
Device run_scenario(const std::string& scenario,
const std::filesystem::path& directory,
std::ostream* output = nullptr);
} // namespace ota
@@ -0,0 +1,124 @@
#include "simulation.hpp"
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
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("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;
}
}