06 - atomic update
This commit is contained in:
1 parent
6d63457737
commit
2bdacaf37a
21 files changed
+1302
-365
No files matched your search
@@ -4,26 +4,27 @@
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
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;
|
||||
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;
|
||||
for (const auto &scenario: ota::scenarios()) output << '|' << scenario;
|
||||
output << "]\n";
|
||||
return &output == &std::cout ? 0 : 2;
|
||||
}
|
||||
try {
|
||||
for (const auto& scenario : ota::scenarios()) {
|
||||
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) {
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << "Simulation failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -8,226 +8,223 @@
|
||||
#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"
|
||||
};
|
||||
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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
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
|
||||
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"; }
|
||||
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.
|
||||
Device::Device(const std::filesystem::path &directory)
|
||||
: path_(directory / "boot-state.txt") {
|
||||
if (!std::filesystem::exists(path_)) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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_);
|
||||
}
|
||||
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");
|
||||
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;
|
||||
}
|
||||
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
|
||||
}// namespace ota
|
||||
@@ -8,61 +8,69 @@
|
||||
|
||||
namespace ota {
|
||||
|
||||
enum class Phase {
|
||||
AConfirmed, PreparingB, BPrepared, TrialPending, TrialRunning,
|
||||
BConfirmed, RecoveryPending, RecoveryRunning, RecoveryChecked,
|
||||
SeparateRecoveryRequired
|
||||
};
|
||||
enum class Phase {
|
||||
AConfirmed,
|
||||
PreparingB,
|
||||
BPrepared,
|
||||
TrialPending,
|
||||
TrialRunning,
|
||||
BConfirmed,
|
||||
RecoveryPending,
|
||||
RecoveryRunning,
|
||||
RecoveryChecked,
|
||||
SeparateRecoveryRequired
|
||||
};
|
||||
|
||||
enum class Slot { A, B };
|
||||
enum class Slot { A,
|
||||
B };
|
||||
|
||||
const char* name(Phase phase);
|
||||
const char* name(Slot slot);
|
||||
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;
|
||||
};
|
||||
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);
|
||||
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_;
|
||||
};
|
||||
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_; }
|
||||
// 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_;
|
||||
};
|
||||
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);
|
||||
const std::vector<std::string> &scenarios();
|
||||
Device run_scenario(const std::string &scenario,
|
||||
const std::filesystem::path &directory,
|
||||
std::ostream *output = nullptr);
|
||||
|
||||
} // namespace ota
|
||||
}// namespace ota
|
||||
@@ -6,108 +6,107 @@
|
||||
#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;
|
||||
void expect(bool condition, const char *message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
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(); });
|
||||
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}) {
|
||||
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::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");
|
||||
expect(device.boot() == ota::Slot::A, "Exhausted trial did not return to A");
|
||||
expect(device.state().phase == ota::Phase::RecoveryRunning, "Recovery checks skipped");
|
||||
}
|
||||
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";
|
||||
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");
|
||||
}
|
||||
try {
|
||||
const ota::Device device(directory.path());
|
||||
} catch (const std::runtime_error&) {
|
||||
return;
|
||||
|
||||
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); });
|
||||
}
|
||||
throw std::runtime_error("Unreadable state was accepted");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
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 {
|
||||
@@ -117,7 +116,7 @@ int main() {
|
||||
confirmation_requires_boot();
|
||||
unreadable_state();
|
||||
std::cout << "All recovery invariant checks passed\n";
|
||||
} catch (const std::exception& error) {
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << "Test failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -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