next chapter

This commit is contained in:
deeaitch committed 2026-10-25 08:31:08 -04:00
1 parent 9385583955
commit c98eccc057
21 files changed
+1054 -11

No files matched your search

@@ -0,0 +1,23 @@
cmake_minimum_required(VERSION 3.20)
project(rollback_policy LANGUAGES CXX)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(rollback-model simulation.cpp)
target_compile_features(rollback-model PUBLIC cxx_std_17)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(rollback-model PRIVATE -Wall -Wextra -Wpedantic)
endif()
add_executable(rollback-simulate main.cpp)
target_link_libraries(rollback-simulate PRIVATE rollback-model)
include(CTest)
if(BUILD_TESTING)
add_executable(rollback-tests tests.cpp)
target_link_libraries(rollback-tests PRIVATE rollback-model)
add_test(NAME recovery-policy-invariants COMMAND rollback-tests)
add_test(NAME all-scenarios COMMAND rollback-simulate all)
add_test(NAME cli-help COMMAND rollback-simulate --help)
add_test(NAME cli-invalid COMMAND rollback-simulate invalid)
set_tests_properties(cli-invalid PROPERTIES WILL_FAIL TRUE)
endif()
@@ -0,0 +1,49 @@
#include "simulation.hpp"
#include <exception>
#include <iostream>
#include <string>
namespace model = ota::rollback;
namespace {
void usage(std::ostream &output) {
output << "Usage: rollback-simulate [all|SCENARIO|--help]\nScenarios:\n";
for (const auto &scenario: model::scenarios()) output << " " << scenario << '\n';
}
void run(const std::string &scenario) {
const auto result = model::run_scenario(scenario);
std::cout << scenario << ": " << model::name(result.phase)
<< "; running=" << (result.running == model::Slot::A ? "A" : "B")
<< "; boots=" << result.boots_requested
<< "; attempts-left=" << result.attempts_left
<< "; records-lost=" << result.records_lost
<< "; failed-release-blocked=" << (result.blocked_releases.count(result.failed_release) != 0)
<< "; " << result.recovery_reason << '\n';
}
}// namespace
int main(int argc, char **argv) {
if (argc == 2 && std::string(argv[1]) == "--help") {
usage(std::cout);
return 0;
}
if (argc > 2) {
usage(std::cerr);
return 1;
}
try {
const std::string requested = argc == 2 ? argv[1] : "all";
if (requested == "all") {
for (const auto &scenario: model::scenarios()) run(scenario);
} else
run(requested);
} catch (const std::exception &error) {
std::cerr << error.what() << '\n';
usage(std::cerr);
return 1;
}
}
@@ -0,0 +1,70 @@
# Rollback policy simulation
This C++17 standard-library example accompanies [chapter 07](../../../docs/07-rollback-and-recovery.md), [ADR-020](../../../adr/ADR-020-define-rollback-eligibility-and-retention.md), [ADR-021](../../../adr/ADR-021-preserve-data-compatibility-during-trial.md), and [ADR-022](../../../adr/ADR-022-bound-recovery-and-block-failed-system-releases.md). It models recovery decisions after B fails, including data compatibility, bounded attempts, and a persistent failed-release block.
Build with CMake 3.20 or later and a C++17 compiler from the repository root. No third-party libraries are needed:
```sh
cmake -S examples/simulations/rollback-policy -B /tmp/ota-rollback-build -DCMAKE_BUILD_TYPE=Debug
cmake --build /tmp/ota-rollback-build
/tmp/ota-rollback-build/rollback-simulate all
/tmp/ota-rollback-build/rollback-simulate backup-restore
ctest --test-dir /tmp/ota-rollback-build --output-on-failure
```
No argument also runs all scenarios. `--help` lists their names. Invalid arguments return a nonzero status.
## Scenarios
| Scenario | Expected result |
|---|---|
| `success` | Compatible data; A passes checks; recovery succeeds. |
| `incompatible-data` | A cannot read schema 2; no approved restore; stop without changing data or booting A. |
| `backup-restore` | Approved schema-1 backup replaces schema-2 data; A passes; two live record IDs are lost. |
| `restart-during-recovery` | Reset before A completes checks; another boot consumes the remaining attempt and passes. |
| `recovery-failure` | A explicitly fails local checks; enter separate recovery immediately. |
| `blocked-release` | After recovery and reset, rediscovering system-2 leaves it blocked. |
| `attempts-exhausted` | Two A boots fail to reach completed checks; no third boot is allowed. |
| `window-closed` | Retained A is outside the promised rollback window; do not select it. |
| `target-unavailable` | Supplied target eligibility fails; do not select A. |
The backup scenarios explore an alternative to ADR-021's compatible shared-data trial policy. They are not permission for incompatible writes during that reference design's trial. A real early-boot failure would need a recovery environment able to restore data before A uses it.
## State and Ownership
`Storage` represents saved boot-control and supervisor records plus data. It survives reconstruction of `Coordinator`; nothing is persisted to real files. The normal path is:
```text
Idle -> Assess -> [RestoreData] -> SelectPrevious -> CheckPrevious -> Recovered
| | | |
+-----------+----------------+-----------------+--> SeparateRecovery
```
`start()` saves the failure reason, exact release block, and recovery intent before taking action. Calling it again is rejected. Resume the saved phase instead. During recovery, automatic offers are excluded. `can_offer()` checks only that exclusion and the local failed-release block; it does not implement signature, hardware, or full admission policy.
The modeled boot-control transition saves an attempt before handoff to A. Creating another coordinator does not consume or refresh attempts. `after_reset()` represents an actual device reset: an A boot that had not completed checks must go through another accounted handoff. After the illustrative budget of two boots is exhausted, the result is separate recovery. An explicit failed check stops sooner.
Data consists of a schema number and unique record IDs. A accepts schema 1. Backup validity and restore permission are supplied inputs; the model counts live IDs missing from the restored copy. A completed restore is not repeated on restart. Terminal recovery does not restore the backup over later records.
`running` records the last modeled handoff. At separate recovery it is diagnostic history, not evidence that a system is healthy or that a rescue environment started. Pending results and blocks are retained; no network transport or block-removal API is implemented.
## Checks and Limits
[tests.cpp](tests.cpp) checks all scenario outcomes, reset boundaries before and after restore and boot, data-loss accounting, block retention, exclusion of competing offers, finite attempt consumption, check-before-success ordering, invalid or unauthorized backups, and changes in eligibility before an action. The checks use exceptions and remain enabled in Release builds.
Each transition is assumed to complete durably. Data replacement and its phase change form one assumed operation here. The example does not test a crash inside a restore or between separate real boot-control and journal writes. Real implementations need their own reconciliation rules, atomic data selection or restartable restore procedure, and platform failure injection.
The model supplies eligibility and health outcomes. It does not validate actual artifacts, implement signatures or security anti-rollback, interpret a real schema, run a watchdog, measure deadlines, invoke a package manager, or enter a rescue system. A closed window and an unavailable target are policy inputs rather than a retention implementation. There are no concurrent writers. The block covers one failed update on one device.
The [trial boot simulation](../ab-trial-boot/readme.md) covers boot mechanics; the [interrupted installation model](../interrupted-installation/readme.md) covers preparation and activation. The three programs are independent, not an integrated updater.
## Sources and Diagrams
- [simulation.hpp](simulation.hpp) and [simulation.cpp](simulation.cpp): saved state, policy transitions, and scenarios.
- [main.cpp](main.cpp): scenario runner.
- [tests.cpp](tests.cpp) and [CMakeLists.txt](CMakeLists.txt): invariants and CTest integration.
- Rollback policy: [PlantUML](../../../diagrams/plantuml/rollback-policy.puml), [PNG](../../../diagrams/rendered/rollback-policy.png).
- Recovery sequence: [PlantUML](../../../diagrams/plantuml/rollback-recovery-sequence.puml), [PNG](../../../diagrams/rendered/rollback-recovery-sequence.png).
- Rollback window: [PlantUML](../../../diagrams/plantuml/rollback-window.puml), [PNG](../../../diagrams/rendered/rollback-window.png).
Format C++ sources with the repository's root `.clang-format`. Diagrams include editable PlantUML sources and PNG previews.
@@ -0,0 +1,193 @@
#include "simulation.hpp"
#include <algorithm>
#include <stdexcept>
namespace ota::rollback {
namespace {
bool compatible(const Data &data) {
return data.schema == 1;
}
bool can_restore(const Storage &storage) {
return storage.restore_authorized && storage.backup_valid && storage.backup && compatible(*storage.backup);
}
bool eligible(const Storage &storage) {
return storage.window_open && storage.target_eligible;
}
void finish(Coordinator &coordinator, Storage &storage, bool healthy = true) {
for (int steps = 0; !terminal(storage.phase); ++steps) {
if (steps >= 16) throw std::runtime_error("Recovery did not terminate");
coordinator.advance(healthy);
}
}
}// namespace
Coordinator::Coordinator(Storage &storage) : storage_(storage) {}
void Coordinator::start(const std::string &reason) {
if (storage_.phase != Phase::Idle) throw std::logic_error("Recovery already started; resume saved state");
if (reason.empty() || storage_.failed_release.empty() || storage_.previous_release.empty() || storage_.failed_release == storage_.previous_release)
throw std::logic_error("Recovery requires distinct release identities and a failure reason");
storage_.failure_reason = reason;
storage_.blocked_releases.insert(storage_.failed_release);
storage_.report_pending = true;
storage_.phase = Phase::Assess;
}
void Coordinator::stop(const std::string &reason) {
storage_.recovery_reason = reason;
storage_.phase = Phase::SeparateRecovery;
}
void Coordinator::advance(bool previous_healthy) {
switch (storage_.phase) {
case Phase::Idle:
throw std::logic_error("Save recovery intent before taking action");
case Phase::Assess:
if (!eligible(storage_)) stop("previous release unavailable or rollback window closed");
else if (storage_.attempts_left <= 0)
stop("recovery boot budget exhausted");
else if (compatible(storage_.data))
storage_.phase = Phase::SelectPrevious;
else if (can_restore(storage_))
storage_.phase = Phase::RestoreData;
else
stop("incompatible data; no authorized valid restore");
break;
case Phase::RestoreData: {
if (!eligible(storage_) || storage_.attempts_left <= 0 || !can_restore(storage_)) {
stop("restore prerequisites no longer hold");
break;
}
// Record IDs are unique. Count live records absent from the backup.
const auto &saved = storage_.backup->records;
storage_.records_lost = static_cast<std::size_t>(std::count_if(
storage_.data.records.begin(), storage_.data.records.end(),
[&](int record) { return std::find(saved.begin(), saved.end(), record) == saved.end(); }));
storage_.data = *storage_.backup;
++storage_.restores;
// Data replacement and this progress transition are one assumed durable
// operation in this model. Real restores need their own recovery protocol.
storage_.phase = Phase::SelectPrevious;
break;
}
case Phase::SelectPrevious:
if (!eligible(storage_) || !compatible(storage_.data)) {
stop("previous release or data no longer eligible");
break;
}
if (storage_.attempts_left <= 0) {
stop("recovery boot budget exhausted");
break;
}
// Abstract boot control saves the consumed attempt before handoff.
--storage_.attempts_left;
++storage_.boots_requested;
storage_.running = Slot::A;
storage_.phase = Phase::CheckPrevious;
break;
case Phase::CheckPrevious:
if (!eligible(storage_) || !compatible(storage_.data) || storage_.running != Slot::A)
stop("recovery prerequisites changed before checks");
else if (!previous_healthy)
stop("previous release failed local checks");
else {
storage_.recovery_reason = "previous release passed local checks";
storage_.phase = Phase::Recovered;
}
break;
case Phase::Recovered:
case Phase::SeparateRecovery:
break;
}
}
void Coordinator::after_reset() {
// A reset before completed checks requires another accounted boot. Recreating
// the Linux coordinator alone does not imply a device reset or new attempt.
if (storage_.phase == Phase::CheckPrevious) storage_.phase = Phase::SelectPrevious;
}
bool terminal(Phase phase) {
return phase == Phase::Recovered || phase == Phase::SeparateRecovery;
}
bool can_offer(const Storage &storage, const std::string &release) {
// This models the local block and exclusion only, not full update admission.
return (storage.phase == Phase::Idle || storage.phase == Phase::Recovered) &&
storage.blocked_releases.count(release) == 0;
}
const char *name(Phase phase) {
switch (phase) {
case Phase::Idle:
return "idle";
case Phase::Assess:
return "assess";
case Phase::RestoreData:
return "restore-data";
case Phase::SelectPrevious:
return "select-previous";
case Phase::CheckPrevious:
return "check-previous";
case Phase::Recovered:
return "recovered";
case Phase::SeparateRecovery:
return "separate-recovery";
}
throw std::logic_error("Unknown recovery phase");
}
const std::vector<std::string> &scenarios() {
static const std::vector<std::string> values{
"success", "incompatible-data", "backup-restore", "restart-during-recovery",
"recovery-failure", "blocked-release", "attempts-exhausted", "window-closed", "target-unavailable"};
return values;
}
Storage run_scenario(const std::string &scenario) {
if (std::find(scenarios().begin(), scenarios().end(), scenario) == scenarios().end())
throw std::invalid_argument("Unknown scenario: " + scenario);
Storage storage;
if (scenario == "incompatible-data" || scenario == "backup-restore") storage.data.schema = 2;
if (scenario == "backup-restore") {
storage.backup = Data{1, {1, 2}};
storage.backup_valid = true;
storage.restore_authorized = true;
}
if (scenario == "window-closed") storage.window_open = false;
if (scenario == "target-unavailable") storage.target_eligible = false;
{
Coordinator coordinator(storage);
coordinator.start("B failed required local checks");
if (scenario == "restart-during-recovery" || scenario == "attempts-exhausted") {
coordinator.advance();// Assess.
coordinator.advance();// First A boot; checks not completed.
} else {
finish(coordinator, storage, scenario != "recovery-failure");
}
}
if (scenario == "restart-during-recovery" || scenario == "attempts-exhausted") {
Coordinator restarted(storage);
restarted.after_reset();
if (scenario == "attempts-exhausted") {
restarted.advance();// Second A boot also fails to reach completed checks.
restarted.after_reset();
}
finish(restarted, storage);
}
if (scenario == "blocked-release") {
Coordinator restarted(storage);
restarted.after_reset();
if (can_offer(storage, storage.failed_release)) throw std::runtime_error("Failed release was offered again");
}
return storage;
}
}// namespace ota::rollback
@@ -0,0 +1,67 @@
#pragma once
#include <cstddef>
#include <optional>
#include <set>
#include <string>
#include <vector>
namespace ota::rollback {
enum class Phase { Idle,
Assess,
RestoreData,
SelectPrevious,
CheckPrevious,
Recovered,
SeparateRecovery };
enum class Slot { A,
B };
struct Data {
int schema = 1;
std::vector<int> records{1, 2, 3, 4};
};
// These are assumed complete durable records, not a storage implementation.
struct Storage {
Phase phase = Phase::Idle;
Slot running = Slot::B;
std::string failed_release = "system-2";
std::string previous_release = "system-1";
std::set<std::string> blocked_releases;
std::string failure_reason;
std::string recovery_reason;
bool report_pending = false;
bool window_open = true;
bool target_eligible = true;
int attempts_left = 2;
int boots_requested = 0;
Data data;
std::optional<Data> backup;
bool backup_valid = false;
bool restore_authorized = false;
std::size_t records_lost = 0;
int restores = 0;
};
class Coordinator {
public:
explicit Coordinator(Storage &storage);
void start(const std::string &reason);
void advance(bool previous_healthy = true);
// Call once for an actual reset, not merely a coordinator restart.
void after_reset();
private:
Storage &storage_;
void stop(const std::string &reason);
};
bool terminal(Phase phase);
bool can_offer(const Storage &storage, const std::string &release);
const char *name(Phase phase);
const std::vector<std::string> &scenarios();
Storage run_scenario(const std::string &scenario);
}// namespace ota::rollback
@@ -0,0 +1,161 @@
#include "simulation.hpp"
#include <iostream>
#include <stdexcept>
namespace model = ota::rollback;
namespace {
void expect(bool condition, const char *message) {
if (!condition) throw std::runtime_error(message);
}
template<class Operation>
void rejected(Operation operation) {
try {
operation();
} catch (const std::logic_error &) {
return;
}
throw std::runtime_error("Invalid operation accepted");
}
void finish(model::Storage &storage) {
model::Coordinator coordinator(storage);
for (int steps = 0; !model::terminal(storage.phase); ++steps) {
expect(steps < 16, "Recovery did not terminate");
coordinator.advance();
}
}
void scenario_outcomes() {
for (const auto &scenario: model::scenarios()) {
const auto storage = model::run_scenario(scenario);
const bool recovered = scenario == "success" || scenario == "backup-restore" ||
scenario == "restart-during-recovery" || scenario == "blocked-release";
expect(storage.phase == (recovered ? model::Phase::Recovered : model::Phase::SeparateRecovery), "Wrong scenario outcome");
expect(storage.blocked_releases.count("system-2") == 1, "Failed release block lost");
expect(storage.report_pending && !storage.failure_reason.empty() && !storage.recovery_reason.empty(), "Recovery diagnosis lost");
expect(storage.attempts_left >= 0 && storage.boots_requested <= 2, "Boot budget violated");
if (recovered) {
expect(storage.running == model::Slot::A && storage.data.schema == 1, "Recovered without usable A and data");
expect(model::can_offer(storage, "system-3"), "Different release cannot reach normal admission");
}
expect(!model::can_offer(storage, "system-2"), "Failed release automatically retried");
}
const auto restored = model::run_scenario("backup-restore");
expect(restored.data.records == std::vector<int>({1, 2}) && restored.records_lost == 2 && restored.restores == 1, "Restore data loss not accounted");
const auto incompatible = model::run_scenario("incompatible-data");
expect(incompatible.data.schema == 2 && incompatible.data.records.size() == 4 && incompatible.boots_requested == 0, "Unsafe rollback changed data or booted A");
const auto exhausted = model::run_scenario("attempts-exhausted");
expect(exhausted.attempts_left == 0 && exhausted.boots_requested == 2, "Reset refreshed recovery attempts");
}
void reset_boundaries() {
// Cut after intent, assessment, restore, boot handoff, and completed checks.
for (int cut = 0; cut <= 4; ++cut) {
model::Storage storage;
storage.data.schema = 2;
storage.backup = model::Data{1, {1, 2}};
storage.backup_valid = true;
storage.restore_authorized = true;
{
model::Coordinator coordinator(storage);
coordinator.start("trial health failure");
for (int step = 0; step < cut; ++step) coordinator.advance();
}
const int consumed = storage.boots_requested;
model::Coordinator restarted(storage);
restarted.after_reset();
expect(storage.boots_requested == consumed && storage.attempts_left == 2 - consumed, "Reset changed saved budget");
rejected([&] { restarted.start("rediscovered failure"); });
finish(storage);
expect(storage.phase == model::Phase::Recovered, "Reset prevented recovery");
expect(storage.restores == 1 && storage.records_lost == 2, "Reset repeated backup restore");
// Data acquired after recovery must not be replaced by the old backup.
storage.data.records.push_back(5);
model::Coordinator next(storage);
next.after_reset();
next.advance();
expect(storage.data.records == std::vector<int>({1, 2, 5}) && storage.restores == 1, "Completed recovery restored old backup again");
}
}
void admission_and_guards() {
model::Storage storage;
model::Coordinator coordinator(storage);
rejected([&] { coordinator.advance(); });
rejected([&] { coordinator.start(""); });
coordinator.start("local health failure");
expect(storage.blocked_releases.count(storage.failed_release) == 1 && storage.boots_requested == 0, "Recovery acted before saving block");
expect(!model::can_offer(storage, "system-3"), "Competing update admitted during recovery");
coordinator.advance();
coordinator.advance();
expect(storage.phase == model::Phase::CheckPrevious, "Boot handoff reported success before checks");
model::Coordinator process_restart(storage);
process_restart.advance(false);
expect(storage.phase == model::Phase::SeparateRecovery && storage.boots_requested == 1, "Failed recovery did not stop");
process_restart.after_reset();
process_restart.advance();
expect(storage.phase == model::Phase::SeparateRecovery && storage.boots_requested == 1, "Terminal failure started another boot");
}
void restore_guards() {
for (int fault = 0; fault < 4; ++fault) {
model::Storage storage;
storage.data.schema = 2;
storage.backup = model::Data{1, {1, 2}};
storage.restore_authorized = true;
storage.backup_valid = true;
if (fault == 0) storage.restore_authorized = false;
if (fault == 1) storage.backup_valid = false;
if (fault == 2) storage.backup->schema = 2;
if (fault == 3) storage.backup.reset();
model::Coordinator coordinator(storage);
coordinator.start("trial failed after data change");
finish(storage);
expect(storage.phase == model::Phase::SeparateRecovery && storage.restores == 0 && storage.boots_requested == 0, "Invalid restore accepted");
expect(storage.data.schema == 2 && storage.data.records.size() == 4, "Rejected restore changed live data");
}
model::Storage storage;
storage.data.schema = 2;
storage.backup = model::Data{1, {1, 2}};
storage.backup_valid = storage.restore_authorized = true;
model::Coordinator coordinator(storage);
coordinator.start("trial failed");
coordinator.advance();
storage.restore_authorized = false;
coordinator.advance();
expect(storage.phase == model::Phase::SeparateRecovery && storage.restores == 0, "Restore ignored revoked permission");
}
void target_changes() {
for (bool change_data: {false, true}) {
model::Storage storage;
model::Coordinator coordinator(storage);
coordinator.start("trial failed");
coordinator.advance();
if (change_data) storage.data.schema = 2;
else
storage.window_open = false;
coordinator.advance();
expect(storage.phase == model::Phase::SeparateRecovery && storage.boots_requested == 0, "Changed prerequisites allowed boot handoff");
}
}
}// namespace
int main() {
try {
scenario_outcomes();
reset_boundaries();
admission_and_guards();
restore_guards();
target_changes();
std::cout << "All rollback policy checks passed\n";
} catch (const std::exception &error) {
std::cerr << "Test failed: " << error.what() << '\n';
return 1;
}
}