Skip to content

Commit 9d4377d

Browse files
[release/5.x] Cherry pick: Ensure that end-of-private-recovery service open transaction is written at-most-once (#6926) (#6950)
Co-authored-by: Eddy Ashton <[email protected]>
1 parent 4c5fa95 commit 9d4377d

File tree

6 files changed

+189
-4
lines changed

6 files changed

+189
-4
lines changed

Diff for: .github/workflows/ci.yml

+8-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
- name: sgx
4646
image: sgx
4747
nodes: [self-hosted, 1ES.Pool=gha-sgx-ccf-sub]
48-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --device /dev/sgx_enclave:/dev/sgx_enclave --device /dev/sgx_provision:/dev/sgx_provision -v /dev/sgx:/dev/sgx -v /lib/modules:/lib/modules:ro
48+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --device /dev/sgx_enclave:/dev/sgx_enclave --device /dev/sgx_provision:/dev/sgx_provision -v /dev/sgx:/dev/sgx -v /lib/modules:/lib/modules:ro
4949
runs-on: ${{ matrix.platform.nodes }}
5050
container:
5151
image: ghcr.io/microsoft/ccf/ci/${{ matrix.platform.image }}:build-05-12-2024
@@ -80,6 +80,12 @@ jobs:
8080
shell: bash
8181
if: "${{ matrix.platform.name == 'snp' }}"
8282

83+
- name: "Install extra dependencies"
84+
run: |
85+
# For backported #6926.
86+
sudo apt install strace -y
87+
shell: bash
88+
8389
- name: "Test ${{ matrix.platform.name }}"
8490
run: |
8591
set -ex
@@ -92,7 +98,7 @@ jobs:
9298
# All other acceptably fast tests, mostly end-to-end
9399
./tests.sh --timeout 360 --output-on-failure -LE "benchmark|perf|protocolstest|suite|unit"
94100
# Partitions tests
95-
./tests.sh --timeout 240 --output-on-failure -L partitions -C partitions
101+
./tests.sh --timeout 360 --output-on-failure -L partitions -C partitions
96102
shell: bash
97103
if: "${{ matrix.platform.name != 'snp' }}" # Needs 1ES Pool support
98104

Diff for: .github/workflows/release.yml

+6
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ jobs:
114114
shell: bash
115115
if: ${{ matrix.platform.name != 'snp' }}
116116

117+
- name: "Install extra dependencies"
118+
run: |
119+
# For backported #6926.
120+
sudo apt install strace -y
121+
shell: bash
122+
117123
- name: "Test ${{ matrix.platform.name }}"
118124
run: |
119125
set -ex

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1212
### Fixed
1313

1414
- Improved locking in indexing system, to remove race conditions which were possible when accessing historical state (#6886).
15+
- Fixed a bug which could produce an invalid secret chain (containing duplicate ledger secret sealing entries) in the ledger if an election occurred during private recovery (#6926). Comes with #6912 as a prerequisite.
1516

1617
## [5.0.14]
1718

Diff for: src/node/node_state.h

+27-1
Original file line numberDiff line numberDiff line change
@@ -1064,7 +1064,10 @@ namespace ccf
10641064
{
10651065
auto entry = ::consensus::LedgerEnclave::get_entry(data, size);
10661066

1067-
LOG_INFO_FMT("Deserialising private ledger entry [{}]", entry.size());
1067+
LOG_INFO_FMT(
1068+
"Deserialising private ledger entry {} [{}]",
1069+
last_recovered_idx + 1,
1070+
entry.size());
10681071

10691072
// When reading the private ledger, deserialise in the recovery store
10701073
ccf::kv::ApplyResult result = ccf::kv::ApplyResult::FAIL;
@@ -1148,6 +1151,29 @@ namespace ccf
11481151
{
11491152
auto tx = network.tables->create_tx();
11501153

1154+
{
1155+
// Ensure this transition happens at-most-once, by checking that no
1156+
// other node has already advanced the state
1157+
auto service = tx.ro<ccf::Service>(Tables::SERVICE);
1158+
auto active_service = service->get();
1159+
1160+
if (!active_service.has_value())
1161+
{
1162+
throw std::logic_error(fmt::format(
1163+
"Error in {}: no value in {}", __func__, Tables::SERVICE));
1164+
}
1165+
1166+
if (
1167+
active_service->status !=
1168+
ServiceStatus::WAITING_FOR_RECOVERY_SHARES)
1169+
{
1170+
throw std::logic_error(fmt::format(
1171+
"Error in {}: current service status is {}",
1172+
__func__,
1173+
active_service->status));
1174+
}
1175+
}
1176+
11511177
// Clear recovery shares that were submitted to initiate the recovery
11521178
// procedure
11531179
ShareManager::clear_submitted_recovery_shares(tx);

Diff for: tests/infra/network.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1662,7 +1662,7 @@ def save_service_identity(self, args):
16621662
with open(previous_identity, "w", encoding="utf-8") as f:
16631663
f.write(current_ident)
16641664
args.previous_service_identity_file = previous_identity
1665-
return args
1665+
return current_ident
16661666

16671667
def identity(self, name=None):
16681668
if name is not None:

Diff for: tests/partitions_test.py

+146
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import contextlib
1818
import ccf.ledger
1919
from reconfiguration import test_ledger_invariants
20+
import subprocess
2021

2122
from loguru import logger as LOG
2223

@@ -711,6 +712,150 @@ def wait_for_new_view(node, original_view, timeout_multiplier):
711712
return network
712713

713714

715+
@reqs.supports_methods("/app/log/public")
716+
def test_recovery_elections(orig_network, args):
717+
# Ensure we have 3 nodes
718+
original_size = orig_network.resize(3, args)
719+
720+
old_primary, _ = orig_network.find_nodes()
721+
with old_primary.client("user0") as c:
722+
LOG.warning("Writing some initial state")
723+
for _ in range(300):
724+
r = c.post(
725+
"/app/log/public",
726+
{
727+
"id": 42,
728+
"msg": "Uninteresting recoverable transactions",
729+
},
730+
)
731+
assert r.status_code == 200, r
732+
733+
r = c.get("/node/network")
734+
assert r.status_code == 200, r
735+
previous_identity = orig_network.save_service_identity(args)
736+
c.wait_for_commit(
737+
orig_network.consortium.set_recovery_threshold(old_primary, 1)
738+
)
739+
orig_network.stop_all_nodes(skip_verification=True)
740+
current_ledger_dir, committed_ledger_dirs = old_primary.get_ledger()
741+
742+
# Create a recovery network, where we will manually take the recovery steps (transition to open and submit share)
743+
network = infra.network.Network(
744+
args.nodes,
745+
args.binary_dir,
746+
args.debug_nodes,
747+
args.perf_nodes,
748+
existing_network=orig_network,
749+
)
750+
network.start_in_recovery(
751+
args,
752+
ledger_dir=current_ledger_dir,
753+
committed_ledger_dirs=committed_ledger_dirs,
754+
)
755+
new_primary, new_backups = network.find_nodes()
756+
network.consortium.transition_service_to_open(
757+
new_primary, previous_service_identity=previous_identity
758+
)
759+
760+
with new_primary.client("user0") as c:
761+
previous_identity = network.save_service_identity(args)
762+
763+
member = network.consortium.get_active_recovery_members()[0]
764+
765+
# We need to delay a backup's private recovery process until:
766+
# - The primary has completed its private recovery, and fully opened the network
767+
# - The backup has called and won an election
768+
# So that the backup node _is primary_ at the point it completes private recovery.
769+
# We force the delay by injecting a delay into the file operations of the backup,
770+
# and force an election (after the primary has completed its recovery) by killing
771+
# the original primary node.
772+
backup = new_backups[0]
773+
LOG.info(f"Using strace to inject delays in file IO of {backup}")
774+
assert not backup.remote.check_done()
775+
776+
strace_command = [
777+
"strace",
778+
f"--attach={backup.remote.remote.proc.pid}",
779+
"--inject=lseek:delay_exit=10s",
780+
"-tt",
781+
"--trace=lseek,read,open,openat",
782+
"--output=strace_output.txt",
783+
]
784+
LOG.warning(f"About to run strace: {strace_command}")
785+
strace_process = subprocess.Popen(
786+
strace_command,
787+
stdout=subprocess.PIPE,
788+
stderr=subprocess.PIPE,
789+
)
790+
791+
member.get_and_submit_recovery_share(new_primary)
792+
network.recovery_count += 1
793+
794+
LOG.info("Confirming that primary completes private recovery")
795+
network.wait_for_state(
796+
new_primary,
797+
infra.node.State.PART_OF_NETWORK.value,
798+
timeout=30,
799+
)
800+
801+
election_s = args.election_timeout_ms / 1000
802+
LOG.info(
803+
f"Holding backup stalled via strace for {election_s}, to trigger an election"
804+
)
805+
time.sleep(election_s)
806+
807+
# If strace failed to stall the node, the rest of the test is meaningless.
808+
try:
809+
strace_process.communicate(timeout=1)
810+
except subprocess.TimeoutExpired:
811+
assert strace_process.returncode is None, strace_process.returncode
812+
else:
813+
assert (
814+
False
815+
), f"strace must not have been completed yet (retcode: {strace_process.returncode})"
816+
817+
LOG.info("Ending strace, and terminating primary node")
818+
strace_process.terminate()
819+
strace_process.communicate()
820+
821+
new_primary.stop()
822+
823+
LOG.info(
824+
f"Give {backup} time to finish its recovery (including becoming primary), and confirm that it dies in the process"
825+
)
826+
time.sleep(election_s)
827+
# The result of all of that is that this node, which had become primary while it
828+
# completed its private recovery, crashed at the end of recovery (rather than)
829+
# producing an invalid ledger)
830+
assert backup.remote.check_done()
831+
832+
network.ignore_errors_on_shutdown()
833+
network.stop_all_nodes(skip_verification=True)
834+
current_ledger_dir, committed_ledger_dirs = backup.get_ledger()
835+
836+
LOG.info(
837+
"Trying a further recovery, to confirm that the ledger is in a recoverable state"
838+
)
839+
recovery_network = infra.network.Network(
840+
args.nodes,
841+
args.binary_dir,
842+
args.debug_nodes,
843+
args.perf_nodes,
844+
existing_network=network,
845+
)
846+
recovery_network.start_in_recovery(
847+
args,
848+
ledger_dir=current_ledger_dir,
849+
committed_ledger_dirs=committed_ledger_dirs,
850+
)
851+
recovery_network.recover(args)
852+
853+
# Restore original network size
854+
recovery_network.resize(original_size, args)
855+
856+
return recovery_network
857+
858+
714859
def run(args):
715860
txs = app.LoggingTxs("user0")
716861

@@ -737,6 +882,7 @@ def run(args):
737882
# HTTP2 doesn't support forwarding
738883
if not args.http2:
739884
test_session_consistency(network, args)
885+
network = test_recovery_elections(network, args)
740886
test_ledger_invariants(network, args)
741887

742888

0 commit comments

Comments
 (0)