Skip to content

Update e2e backup tests for backup-rework CLI changes - #1255

Open
RaunakJalan wants to merge 188 commits into
mainfrom
fix/backup-rework-e2e
Open

Update e2e backup tests for backup-rework CLI changes#1255
RaunakJalan wants to merge 188 commits into
mainfrom
fix/backup-rework-e2e

Conversation

@RaunakJalan

Copy link
Copy Markdown
Collaborator
  • backup import: positional arg → --from-file flag (3 locations)
  • backup restore: remove --cluster-id flag (no longer exists)

EbiRider and others added 30 commits August 12, 2026 18:39
… clone's parent

The all-zeros DR fail-over, root-caused at the RPC level this time. Fail-over
(replicate_lvol_on_target_cluster) left do_replicate=True and the pending
FN_SNAPSHOT_REPLICATION tasks queued on the source. The "dead" source cluster
auto-recovers within minutes, the tasks then complete, and each completion runs
_prune_internal_snapshots for the source volume. Retention keeps only the newest
replicated internal snapshot and deletes the TARGET copies of older ones —
including the snapshot the fail-over volume was just cloned from. That delete
reaches SPDK as bdev_lvol_delete(sync=False) (validated in the spdk_proxy logs:
prune line to delete RPC in ~26ms, same thread), and sync=False frees the blocks
immediately, so every DB-level guard downstream fires after the data is gone.
Observed timeline (2026-08-11 lab): fail-over 21:22:24-37 with 4 replication
tasks still `running`; prune + delete RPC 21:23:54; the controller's soft-delete
guard fired the same second and the monitor guard at 21:24:09 — both too late.
The volume reads zeros from ~90s after a successful fail-over: no filesystem,
md5 mismatch, while every status field says online. Case-1-style migration
cutover is unaffected because it retires the source volume through the normal
path instead of leaving its replication running.

Two-part fix, both ahead of the RPC:

* replicate_lvol_on_target_cluster now calls replication_stop() before
  recording the relationship: do_replicate=False and the pending replication
  tasks cancelled. A failed-over volume no longer lives on the source, so
  there is nothing left to replicate; any later source delta is by definition
  past the RPO the fail-over accepted.

* _prune_internal_snapshots skips (and logs) a target snapshot that a live
  volume is cloned from, keeping the paired source copy too — so no other
  caller can ever issue the fatal delete. An in_deletion clone deliberately
  does not pin the snapshot, so retention cannot deadlock behind a dying clone.

The earlier hardening commits (fail-over point selection, clone-under-lock,
monitor live-clone guard) remain valid but each acted after the sync=False
delete had already freed the blocks; this addresses the mechanism itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to 7250fa0, hardening the dominant failure path of the
2026-08-11 soak: lvol_monitor's repair called add_ns for an
already-bound namespace, SPDK rejected it with -32602 "Invalid
parameters" (nsid taken), and add_lvol_thread returned on the error —
before the listener loop — so the volume lost a PATH rather than just
failing a redundant add, and the repair re-failed identically on every
monitor cycle (20 hits across the two recovered nodes).

The duplicate case is already recognised upstream since 7250fa0 (the
add_ns idempotency probe matches by UUID), so reaching the error branch
now means a real failure or a probe miss. Either way what matters for
the client is whether the namespace is on the subsystem NOW: re-read it
(bounded poll) and continue to listener setup when present; give up
only when the namespace is genuinely absent. The empty-subsystem guard
below stays fail-closed.

Soak: SPDK verification is now a heal GATE rather than a fixed-window
check. Redundant-path re-add after an outage runs on the health-check /
reconcile cadence and legitimately takes minutes (measured 169s for
full hublvol path convergence after a 30s all-node single-NIC outage;
run 2 aborted spuriously because its 75s window undershot exactly
that). The gate blocks the next iteration until every path, policy and
listener has converged — a new outage on top of still-degraded
redundancy would test an unplanned scenario — logs the healing time
per phase as a measurement, and fails only on --path-heal-timeout
(default 900s).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root cause of the all-zeros DR fail-over, fourth and final layer. The fork
addresses replicated cluster writes as (redirect_map_id << 48) | offset — the
top 16 bits of the LBA carry the RECEIVING volume's map id so the target
distrib routes the data into its map, and the end-of-transfer signal is only
recognised as a control message when tagged (lib/lvol/lvol.c, R26.3 ==
fork-main). redirect_map_id comes from bdev_lvol_transfer's lvol_id parameter.

snapshot_replication started every transfer WITHOUT lvol_id, defaulting to 0:
writes went untagged, the target blob allocated clusters (used_size looked
plausible) but no readable data ever landed in the map, and the completion
signal was written as plain data at LBA 0. Every snapshot replicated this way
is empty; a clone of it — the DR fail-over volume, or even a plain
`snapshot clone` (verified live) — reads zeros with no filesystem while all
metadata (chain, base_snapshot, allocated clusters) looks correct. The
migration runner has always passed lvol_id=tgt_map_id to the same RPC, which
is why planned cutover (case 1) worked while fail-over (case 2) failed 4/4.

Fetch the receiving volume's map_id from the target node at transfer start and
pass it as lvol_id; if it cannot be read, suspend and retry the task instead of
launching a transfer that cannot land.

The transfer→add_clone→convert wiring itself was verified correct — it just
faithfully froze an empty volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add kubectl install to Dockerfile with multi-arch support

* Fix kubectl not found by installing to /usr/local/bin

* replace kubectl binary with kubernetes Python client in collect_logs.py and add missing task-runner services

* fix collect_logs: use since_seconds instead of since_time and replace kubectl with kubernetes Python client
…ap id

Replication transfers were aimed at the receiving volume's own namespace with
no lvol_id. Per the fork's transfer contract (confirmed by the data-plane
design): bulk transfers must go over a HUBLVOL, and the receiving volume's
map id must ride in each write's LBA (top 16 bits, lvol_map.lvol[offset >> 48])
— the demux only exists on a hublvol namespace. The migration runner has always
done hub+map_id and works; replication did neither half. The previous attempt
(e5b1cf6, reverted) added the map id but kept the volume-namespace gateway:
tagged writes at an endpoint with no demux, which failed transfers outright.

Attach the target node's transfer hublvol on the source (reusing
ensure_hub_attached, shared+persistent across cycles like the migration hub),
fetch the receiving volume's map_id from the target, and start the transfer
hub+map_id. Suspend-and-retry the task if either is unavailable. The
per-volume controller detaches at finish go away — there is no per-volume
controller any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sters

Reverts the leader-leg removal from ae46797 (its part 1, the inline
non-leader sync legs, stays). That commit's premise — "the leader's async
delete already removed the blob and unregistered the bdev" — is false:

  - bdev_lvol_delete sync=False ends at bs_delete_blob_finish_async ->
    blob_clear_clusters_async: data clusters cleared, in-memory clone-list
    entries stripped, blob metadata and bdev registration left in place.
    The delete-status "done" (deletion_status=2) means the unmap finished,
    nothing more.
  - The leader's sync=True delete (_vbdev_lvol_destroy is_sync=true) is
    the only operation that unregisters the bdev and deletes the blob md.
    SPDK admits it exactly once the async pass reports done.

Evidence: upgrade run 20260812 (test_major_upgrade-20260812-170049). All
24 lvols of the teardown wave were "deleted" (records removed, subsystems
gone, non-leader registrations gone) while EVERY leader kept every blob
and bdev — end-of-run dumps show all 4 LVSes with their complete object
sets. The follow-up snapshot delete failed EBUSY -16 ("Cannot remove
snapshot because it is open", blobstore.c:11451) because the snapshot's
two children were still alive on the leader (snapshot open_ref=3), and
the soft-delete gate could not protect it: the children's DB records were
already gone, so the snapshot looked clone-free to the control plane.

Why the 20260807 evidence misled:
  - The 4361 "Clone entry not found" errors on the leader are BENIGN:
    blob_get_snapshot_and_clone_entries only logs when the async pass has
    already removed the in-memory entry; the sync delete proceeds and does
    the real work. Noise, not harm.
  - The "0 leftovers without the leader leg" check sampled the 162
    create-rollback objects, which do not represent the regular delete
    path (failed creates often never registered a leader bdev, and the
    end dump postdates lvstore teardown).

Changes:
  - lvol_monitor.process_lvol_delete_finish: leader sync delete restored
    (under the leader's lvstore lock), comment corrected.
  - snapshot_monitor.process_snap_delete_finish: leader sync delete
    restored (with special_delete passthrough), comment corrected.
  - snapshot_controller._rollback_snapshot_bdev: leader sync delete added
    after the bounded completion poll (this path NEVER had one — same
    leak for rolled-back snapshot bdevs), invariant docstring corrected.
  - lvol_controller._delete_lvol_from_all_nodes: comment corrected (the
    inline non-leader legs and fail-closed poll are unchanged).

The "Clone entry not found" storm returns with the leader leg; it is
understood and harmless. Proper long-term fix is SPDK-side: either the
async delete carries through to metadata removal (then the leader leg
and the noise both go away) or the post-async sync walk skips entries
the async pass already stripped.

Unit tier: test_async_delete_poll passes (9/9); ruff and mypy clean.
pytest-style tests need Linux (SIGALRM conftest) and run in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… snapshot

On the target, every replicated snapshot is chained onto its predecessor
(add_clone at receive time): the newest snapshot reads THROUGH the older ones.
Retention ("keep only the last replicated internal snapshot") deleted those
predecessors outright, destroying the shared clusters the survivor depends on.
Observed on the 2026-08-13 lab with transfers finally landing real data
(hub+map_id): a clone of the newest target snapshot had a valid XFS superblock
(its own delta) but an empty tree — everything underneath was gone.

Before pruning a target snapshot, decouple every child SPDK reports
(bdev_lvol_decouple_parent copies the parent's allocated clusters into the
child) on the primary and its online secondary. If any decouple fails, keep
the snapshot pair and retry next pass rather than deleting a parent something
still reads through. New rpc_client wrapper for bdev_lvol_decouple_parent
(fork RPC, {"name": <child>}).

Note tests/unit/test_rollback_sync_delete_all_peers.py fails on current main
independently of this change (5f4315e restored the leader's sync delete;
the test still asserts the pre-restore behaviour).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#1245)

* fix(alerting): use ratio query for cluster capacity alerts to preserve cluster label in fired alerts

* fix(test): update rollback sync delete assertions for leader node

---------

Co-authored-by: hamdykhader <hamdy.khader@gmail.com>
The target_node_id was used authoritatively, not as a hint. It silently
overrode the intended cluster. Because the cluster-id is defined by the
pool, the cluster-id flag is dropped completely.
The introduced API fixes a number of issues with the API v1 metrics, so
it's not compatible with it:
- Dropped size_util / size_prov_util: v1's were lossy (int(used/total*100)),
  not wrong. Adequate for a threshold alert, coarse for graphs. Dividing the
  byte gauges is exact.
- Status as {status="degraded"} label instead of numeric code. The label
  just doesn't hardcode magic numbers and survives map changes.
- health_check absent instead of NaN: Same information, no float sentinel
  for consumers to special-case.
- Dropped date, record_duration, record_start_time, record_end_time:
  Metadata, the record_* trio was never populated by any collector.
- simplyblock_ namespace: Avoids collision with the node and foundationdb
  jobs and allows coexistence with v1 metrics.
- Added pool_name to volume series: Preserves the human-readable $pool
  dropdown that v1 got for free from its (otherwise buggy) label.
…the hub session

Two receive-side steps the migration runner has always performed and
replication never did, both mandatory per the fork's transfer contract:

* bdev_lvol_set_migration_flag on the receiving lvol BEFORE the transfer.
  The flag (a) stamps the receiving blob's writes special_io=1, which the raid
  layer encodes into the LBA and the distrib stack uses for receive-mode
  placement (blobstore.c bs_batch_open_s/special_io), and (b) arms the hub
  write handler's detection of the end-of-transfer signal — hublvol_write only
  routes a 1-page write at page 0 into process_migration_write_request ("add
  this lvol as clone ... mark migrate process completed") when migration_flag
  is set (vbdev_lvol.c:1414). Without the flag the payload lands as ordinary
  client IO and the completion signal is written ONTO LBA 0 as data: the
  receive never finalises and every clone of the converted snapshot reads
  zeros — reproduced with a 3-step repro (replicate one snapshot, clone the
  target copy, read) with no failure injection at all. add_clone/convert clear
  the flag, closing the lifecycle.

* Detach the transfer hub on the source when the cycle finishes (success and
  abort paths). The connect -> transfer -> convert -> disconnect cycle is part
  of the contract; the next cycle re-attaches via ensure_hub_attached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sync

c04cfcb adapted this test to the restored leader sync leg (5f4315e)
with a bare call_count == 2, which would also pass if the leader received
two async deletes and never its sync leg — the exact leak of upgrade run
20260812. Assert the full call list instead: phase-1 async first, then
sync=True. Also correct the module docstring, which still stated the
falsified "never on the leader" protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ive path

The flag drives the distrib-level special_io machinery of INTRA-cluster
migration (copy-on-write context within one cluster's maps); it does not apply
to a cross-cluster receive, where the source cluster's map/COW context does not
exist on the target. It was added in 7ea16df by copying the intra-cluster
migration recipe; reverting that half. The hub connect->transfer->disconnect
session lifecycle from the same commit stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
replication_commit took one fire-and-forget internal snapshot, selected a
cutover base that was 1-2 replication intervals old, and enqueued the final
task — which froze immediately, WITHOUT waiting for anything to replicate. The
writes between the last replicated snapshot and the pre-commit snapshot were
covered neither by the final step's delta (top blob only = writes after the
pre-commit snapshot) nor by anything on the target: every cutover silently
lost up to ~2 intervals of data. Invisible to the harness because fio's
sequential sweep rewrites its whole working set and verify_backlog only checks
recent writes.

The cutover now shrinks the delta iteratively before freezing:

  snapshot #1 (at commit) -> wait until replicated AND converted on the target
  -> IMMEDIATELY snapshot #2 (delta = just the wait window) -> wait again ->
  IMMEDIATELY build the target clone on that last replicated snapshot and run
  the freeze + ANA flip.

replication_commit is now thin (validate, shrink snapshot #1, enqueue); the
final-task runner owns the shrink state machine (bounded by
REPL_CUTOVER_SHRINK_TIMEOUT_SEC, waiting does not burn task retries) and the
clone/map-id/replication-record preparation, so the base is always the freshly
replicated shrink snapshot. Old-style tasks with tgt_* params still run
unchanged (skip shrink + prepare).

Harness: case 1 now writes a baseline fio never touches and md5-verifies it
through the cutover volume after a remount — the ONLY assertion that exercises
the replicated snapshot history (fio's own data always arrives via the final
step); target paths are connected inside the cutover wait loop as soon as the
runner creates the target volume, keeping multipath ahead of the ANA flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cutover enabled the target paths and only then set the source paths
inaccessible. Two windows lose writes: (a) after the final delta is taken and
the freeze lifts, the source is still ANA-optimized until the flip reaches it —
a client write landing there is newer than the delta of record and silently
lost; (b) during the flip itself both source and target are optimized —
dual-writable. The intra-cluster migration runner already fences source
replicas pre-freeze for exactly this reason; the cross-cluster cutover did not.

New order: ALL source paths -> inaccessible (peers first, primary last), THEN
freeze + final delta (nothing can land on the source by any means; the delta is
definitively final), THEN target primary -> optimized, peers -> non_optimized.
Client IO queues during the all-dark window (NVMe multipath semantics), bounded
by freeze + residual delta — seconds, thanks to the delta-shrink rounds.

If the freeze FAILS after the source was fenced, the source paths are restored
(primary optimized, peers non_optimized): nothing moved, the source is still
the authoritative copy, and a failed cutover attempt must not leave the volume
dark.

flip_ana_failover is split into fence_source_paths / enable_target_paths /
restore_source_paths; ordering asserted in tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eive and convert

Two non-leader hazards in the fork's data plane:

* the transfer hub REJECTS receive IO on a non-leader ("receive io for hublvol
  in nonleader mode") — a transfer started against a non-leader target fails
  outright;
* bdev_lvol_convert on a non-leader DEGRADES SILENTLY: the non-leader branch
  marks the blob CLEAN and replies success without persisting anything, so the
  "snapshot" looks converted while its metadata never reached the journal —
  return-value checks cannot catch it.

Gate both: replication verifies the receiving node holds LVS leadership before
starting a transfer and again before add_clone/convert (suspend-and-retry
otherwise); the lvol-migration and batch-migration runners verify leadership
before their converts (fail-and-retry). Shared probe: lvol_controller.
is_node_leader.

Also: setup_repl_test_2clusters ssh_exec gets the reconnect-retry the test
harness already had — a 10054 reset killed a second deployment at the finish
line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ive user

The hub is ONE session per target node. The per-cycle detach added with the
connect->transfer->disconnect contract ripped the shared qpair out from under
the other volumes' in-flight transfers: mass hub IO failures, LVS leadership
churn on the target ("receive io for hublvol in nonleader mode" storms,
observed live 2026-08-13), transfers landing nothing, converts silently
no-oping on flapped leadership. Detach now happens only when no other RUNNING
snapshot-replication task is transferring into the same target node — the
refcount discipline the migration runner's hub_manager embodies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n the remote cluster

Replicated snapshots on the target were standalone blobs (clone=false,
base=null in SPDK): the finish path only chained when snap_ref_id was set,
but internal replication snapshots never populate it, and even then the
lookup matched the remote node against the SOURCE snapshot's instances,
which are source-cluster nodes. bdev_lvol_add_clone was never attempted
(chain_attempts=0 across entire runs). A fail-over clone therefore read
only the last delta and zeros elsewhere, and retention's delete could not
swap-merge segments into a successor — the all-zeros DR fail-over.

Resolve the predecessor by lvol + age (newest older snapshot with a
completed remote copy, snap_ref_id still wins when set), chain to the
remote copy's bdev, and fail-and-retry instead of silently finalizing an
unchained snapshot when a predecessor exists but cannot be resolved.

Harness: mark read-only DB/status pollers replayable so a mid-exec socket
reset (WinError 10054) replays the query instead of aborting the case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etry backoff

The monitor ran one serial pass per cycle: take internal snapshots, then
process EVERY in_deletion record with a full DB fetch and RPCs. Two
consequences observed on the 2026-08-14 lab (1298 records in in_deletion):

- a delete that cannot complete is retried every cycle forever, so the
  in_deletion set only grows and each cycle costs more;
- internal-snapshot creation shares that pass, so it never came around
  again — five replicated volumes went an hour with zero snapshots, which
  looks like 'replication stopped' but is starvation.

Creation now runs for all clusters before any delete work. Deletes are
processed by a bounded pool keyed on the owning volume, so a chain
(clone -> snapshot -> parent) still advances in order on one worker while
different volumes proceed concurrently. Failing deletes get exponential
backoff (5s..5min) instead of a slot every cycle.

Concurrency contract: per-object create+register stays serialized by
object_mutation_lock; every synchronous single-node RPC is mutually
exclusive per node via lvstore_op_lock, which the phase-2 sync deletes now
take individually. That is the creators' key space ('<lvs>@<node8>') — a
whole-lvstore key would be a different key and exclude nothing. DB
finalize stays outside the lock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…discovery


- Store checksums alongside each backup_id as (bk_id, checksums) tuple
  so restores verify against snapshot-time data, not FIO-modified source
- Scope clone ns-rescan to source controller instead of all controllers
  to avoid picking up unrelated devices
- Add nvme ns-rescan to _wait_for_new_ns_device polling loop so kernel
  discovers newly-created namespace children
…kups


- Delete restore lvols after verification to prevent max_lvol exhaustion
  (was accumulating 800+ restore lvols, hitting 50/node subsystem limit)
- Use clone nsid to determine connection strategy: nsid=1 does nvme
  connect (own subsystem), nsid>1 does ns-rescan on live controllers
  (shared parent subsystem)
- Prefer older backup_ids for restores to avoid "Incomplete backups
  in chain" errors from freshly-created backups still uploading to S3
- Capture device baseline before add_clone, matching existing clone
  test patterns
if rst_mount:
self._unmount_and_disconnect(
self.fio_node, rst_mount, rst_id or "")
except Exception:
pass
try:
self._delete_lvol(rst_name, skip_error=True)
except Exception:
Align MAX_ENTITY_COUNT and NUM_SUBSYSTEMS with the 6k class name
(was misconfigured at 12000/60, now 6000/30).
Clone phase now runs all 10 clones concurrently in K8s mode using
threads (Docker mode stays sequential due to device baseline races).
Reduces clone phase from ~4h to ~25min on K8s.

Fix utility pod SELinux relabel failure on OpenShift by setting
securityContext.seLinuxOptions.type=spc_t on checksum pods. Without
this, OpenShift auto-relabels volume mounts causing "bad message"
errors on all clone restore verifications.
…/clone/namespace combos


No deliberate concurrency anywhere in the test — many backups and restores
are dispatched one after another to reach a high total operation count
without engineering a race, to check whether ordinary bulk backup activity
(not just an artificial race) can reproduce backup/restore failures.
Comment thread scripts/setup_repl_test_2clusters.py Fixed
Registers TestBackupHighVolumeCombosSequential under
get_backup_stress_tests() instead of the routine get_backup_tests() suite
- at ~6-7 hours in k8s mode it belongs with BackupStressComprehensive's
budget, not the standard backup suite.

Also attaches a versions=3 retention policy for the whole run so the
service's merge logic (bdev_lvol_s3_merge) has to keep trimming each
lvol's growing backup chain, instead of letting ~7 rounds of backups pile
up unbounded. Logs resulting chain depth per lvol as a merge-at-scale
sanity check.
With the versions=3 policy now attached, an absorbed backup doesn't get
deleted - it transitions to status='merged' (Backup.STATUS_MERGED) and is
no longer restorable. The restore loop was trusting its own
locally-tracked backup_ids[-1], which can go stale once a merge absorbs
it. Now re-resolves against a fresh, status-filtered live backup list
each round and walks backward through the tracked ids to find the newest
one still actually restorable, skipping gracefully if none survive.
Multiple GitHub Actions runners share one VM, and every "Start MinIO
trace logging" step across 10 workflows was using a shared /tmp/mc
binary path plus a pattern-matching pkill -f "mc admin trace" during
setup and teardown. One job's install/cleanup could delete, overwrite,
or kill another concurrently-running job's mc binary/trace process,
causing spurious "installing mc" failures whenever two backup-related
jobs landed on the same VM at once.

Scopes every mc binary path, alias name, and log path to
$GITHUB_RUN_ID, and replaces the global pkill/glob cleanup with
PID- and path-scoped cleanup of only this job's own resources.
_connect_and_mount() is the wrong tool for a namespace child: it's not
an independent NVMe target, it's a new namespace on the parent's
already-connected subsystem. Two compounding bugs caused
"No new block device after connecting" for the second child:

1. Reusing _connect_and_mount() issues a redundant nvme connect and
   does a naive before/after device diff, racing against how fast the
   kernel discovers a namespace added after the controller connected.
2. _unmount_and_disconnect(child_id) was a silent no-op: a namespace
   child has no NQN identity of its own (it lives under the parent's),
   so disconnect_lvol()'s `nvme list-subsys | grep <child_id>` finds
   nothing and the shared controller is never actually torn down -
   which is exactly why the next child's connect attempt found it
   still "already connected".

Fixed by mirroring continuous_backup_stress.py's proven approach:
connect once for the parent, keep the controller alive, and for each
child snapshot the namespace-device list before add_lvol() and poll
with `nvme ns-rescan` until the new device appears - no redundant
connect calls, and the shared controller is disconnected exactly once
at the end via the parent's own id.

RCA: test_ns_child_connect_race_rca_20260902.md
…pgrade test


One-off validation step for R26.3's `sbctl cluster switch-write-protection`
(activates v2 distrib write protection cluster-wide after upgrading from a
pre-v2 release). Runs after the rolling upgrade completes but before the
test waits for FIO to finish, so FIO keeps running as I/O load through the
switch and the second round of storage-node restarts.

Marked TEMP STEP / END TEMP STEP in the code for easy removal after this
validation run.
…tests


storage-node configure's required flag (--max-lvol, then --max-subsys,
then neither) has changed three times across sbcli versions with no
reliable way to infer which era a base branch belongs to from its
name. Rather than change bootstrap-cluster.sh's default behavior
(shared by many parallel pipelines), this adds two opt-in checkboxes
scoped to just the upgrade-test workflows:

- BOOTSTRAP_CONFIGURE_MAX_LVOL / BOOTSTRAP_CONFIGURE_MAX_SUBSYS route
  the max-subsys value to storage-node configure (via --extra-sn-args)
  instead of cluster create, for old/mid-era base versions. Default
  (both unchecked) reproduces today's behavior exactly.
- Also collapses the 5 separate BOOTSTRAP_MAX_SUBSYS/DATA_CHUNKS/
  PARITY_CHUNKS/JOURNAL_PARTITION/HA_JM_COUNT inputs into one
  comma-separated BOOTSTRAP_PARAMS input, parsed back out by a new
  "Parse BOOTSTRAP_PARAMS" step.
- Adds SIMPLYBLOCK_DEPLOY_BRANCH input so the simplyBlockDeploy
  bootstrap-cluster.sh version can be pinned per-run (default: main).
Comment thread e2e/stress_test/continuous_backup_stress.py Fixed
Comment thread e2e/stress_test/continuous_backup_stress.py Fixed
Comment thread e2e/stress_test/continuous_backup_stress.py Fixed
Comment thread scripts/setup_repl_test_2clusters.py Fixed
@RaunakJalan
RaunakJalan force-pushed the fix/backup-rework-e2e branch 2 times, most recently from de01b96 to faa8b7e Compare September 3, 2026 07:43
# Conflicts:
#	.github/workflows/e2e-bootstrap.yml
#	.github/workflows/k8s-native-cross-cluster-restore.yaml
#	.github/workflows/k8s-native-e2e-add-node.yaml
#	.github/workflows/k8s-native-e2e-node-migration.yaml
#	.github/workflows/k8s-native-e2e.yaml
#	.github/workflows/monitoring-suite-docker.yaml
#	.github/workflows/stress-run-bootstrap.yml
#	.github/workflows/upgrade-bootstrap-single.yml
#	.github/workflows/upgrade-bootstrap.yml
#	AGENTS.md
#	docs/replication-policies-design.md
#	e2e/e2e_tests/backup/test_backup_restore.py
#	e2e/e2e_tests/upgrade_tests/k8s_major_upgrade.py
#	e2e/stress_test/mass_create_delete_stress.py
#	scripts/aws_dual_node_outage_soak_multipath.py
#	scripts/deploy_gate_and_soak.py
#	scripts/hotfix_repl_lab.py
#	scripts/setup_perf_test.py
#	scripts/setup_perf_test1.py
#	scripts/setup_perf_test_failure_domain.py
#	scripts/setup_perf_test_multipath.py
#	scripts/setup_repl_test_2clusters.py
#	scripts/stage_and_run_repl_cases.py
#	scripts/start_soak_mp.sh
#	scripts/test_async_replication.py
#	simplyblock_cli/cli.py
#	simplyblock_cli/clibase.py
#	simplyblock_core/AGENTS.md
#	simplyblock_core/cluster_ops.py
#	simplyblock_core/constants.py
#	simplyblock_core/controllers/health_controller.py
#	simplyblock_core/controllers/lvol_controller.py
#	simplyblock_core/controllers/replication_policy_controller.py
#	simplyblock_core/models/cluster.py
#	simplyblock_core/models/replication.py
#	simplyblock_core/models/stats.py
#	simplyblock_core/rpc_client.py
#	simplyblock_core/services/capacity_and_stats_collector.py
#	simplyblock_core/services/health_check_service.py
#	simplyblock_core/services/lvol_monitor.py
#	simplyblock_core/services/main_distr_event_collector.py
#	simplyblock_core/services/snapshot_monitor.py
#	simplyblock_core/services/snapshot_replication.py
#	simplyblock_core/storage_node_ops.py
#	simplyblock_core/test/test_internal_subsystem_exemption.py
#	simplyblock_core/test/test_replication_backlog_reporting.py
#	simplyblock_core/test/test_replication_chain_completeness.py
#	simplyblock_core/test/test_snapshot_instance_handoff.py
#	simplyblock_core/test/test_snapshot_replication_retention.py
#	simplyblock_core/test/test_transfer_hub_heal.py
#	simplyblock_web/api/v2/cluster/__init__.py
#	simplyblock_web/api/v2/cluster/replication.py
#	simplyblock_web/api/v2/cluster/storage_pool/volume/__init__.py
#	simplyblock_web/api/v2/metrics.py
#	tests/unit/test_cluster_spdk_sizing.py
#	tests/unit/test_object_limit_per_lvstore.py
#	tests/unit/test_repair_gating.py
#	tests/unit/web/api/v2/test_cluster_endpoints.py
self.fio_node, info["mount"])
if current == info["checksums"]:
verify_ok += 1
except Exception:
try:
self._unmount_and_disconnect(
self.fio_node, info["mount"], info["id"])
except Exception:
try:
self._detach_policy(
info["policy_id"], "lvol", info["id"])
except Exception:
for name, lid, mnt in lvols:
try:
self._unmount_and_disconnect(self.fio_node, mnt, lid)
except Exception:
try:
if r_mnt:
self._unmount_and_disconnect(self.fio_node, r_mnt, r_id)
except Exception:
pass
try:
self._delete_lvol(rname)
except Exception:
Only exists on this branch (main deleted it in favor of the newer
replication test files); keeping it desynced this branch's non-e2e
test suite from main's.
- Annotate mutable class-level list attributes as ClassVar
  (COMBOS, _CAPACITY_PATTERNS, _REMOVE_API_SERVICES)
- Drop f-prefix from strings with no placeholders in k8s_utils.py
- Bind thread_results as a default arg instead of a loop closure
  in continuous_backup_stress.py to satisfy B023
- Rename ambiguous single-letter `l` loop variables to `lbl`/`ln`
_get_k8s_worker_nqns() (and the identical _get_host_nqn() helper in
test_backup_restore.py) called k8s.k8s._exec_kubectl(), but
_ensure_k8s_utils() already returns the K8sUtils instance directly.
Broke every K8s DHCHAP test that sets up a pool+host at the first
kubectl call, before any DHCHAP-specific logic ran.
Cross-checked every step against k8s_major_upgrade.py and a passing
CI run (25.10.5 -> R26.3): added the missing prometheus-credential
migration step, fixed the Step 7 CR YAML to the current schema,
corrected the cert-manager install method, and documented open vs.
fixed issues (ext4 FEATURE_C12, CRD lifecycle, SPDK image restart).
_ensure_pool_and_sc() and the DHCHAP host-registration step called
add_storage_pool()/add_host_to_pool() on the low-level K8sUtils
returned by _ensure_k8s_utils(), but those methods only exist on the
outer K8sSbcliUtils (self.sbcli_utils). Same class of bug as the
security test fix, found by grepping the pattern across both files.
…nnect

The K8s operator enforces DHCHAP purely from StoragePool.spec.dhchap +
allowedNodes: it derives each allowed node's NQN itself, labels those
nodes, and restricts scheduling via the generated StorageClass/PV
nodeAffinity. No host NQN is ever supplied by a client. The previous
K8s branches of TestLvolCryptoWithDhchap/TestLvolDhchapBidirectional
ported the docker-mode manual `nvme connect --host-nqn` flow as-is,
which doesn't reflect how a real workload (or the operator) actually
enforces the restriction, and never exercised the negative case since
_setup_pool_and_host allowed ALL workers rather than a subset.

- create_utility_pod()/create_fio_job() gain a node_name param to
  hard-pin a pod via spec.nodeName, bypassing the scheduler so a pod
  can be deliberately forced onto a disallowed node.
- New get_pod_events() reads FailedMount events (a Pod event, not a
  container waiting-state reason get_pod_status_detail could see).
- New _k8s_setup_dhchap_pool_subset()/_k8s_verify_pod_scheduling()
  helpers: pool's allowedNodes is a strict subset of workers, and a
  pod pinned to an allowed node must mount; pinned to the disallowed
  one must fail with FailedMount.
- TestLvolCryptoWithDhchap, TestLvolDhchapBidirectional, and
  TestDhchapPodScheduling now use this for their K8s branch (docker
  branches unchanged — manual connect-string is the correct native
  approach there). TestDhchapPodScheduling also gains the missing
  negative case (pod on a disallowed node).
… limit

Every lvol creation in RandomMultiClientMultiFailoverAllNodesTest was
failing with HTTP 400 ("exceeds the hard limit of 50 namespaces per
subsystem"), including the retry, for all 40 lvols, from the first
attempt onward. The test ran its full multi-hour outage cycle against
zero actual volumes. Lowered to 30, comfortably under the limit.
add_storage_pool() blindly reused ANY existing sbcli-visible pool
regardless of the caller's dhchap/allowed_nodes request. On a shared
test cluster with leftover pools from unrelated tests (e.g.
"encryption-pool"), a DHCHAP test asking for allowedNodes-restricted
access got handed back a pool with no such restriction at all — so a
pod pinned to a deliberately-disallowed node mounted it just fine,
because the enforcement was never actually configured on the pool
being used.

Now: a dhchap/allowed_nodes request only reuses an existing pool if
its StoragePool CRD already has that exact dhchap+allowedNodes config;
otherwise it creates a dedicated, uniquely-named pool. Also scoped the
CRD-existence and Terminating-CRD checks to that dedicated pool's own
resource name (previously "any StoragePool CRD exists in the
namespace" would skip creating ours and hand back whichever unrelated
pool the operator listed first). Callers that don't pass
dhchap/allowed_nodes keep the original, proven blind-reuse behavior
unchanged.
DHCHAP enforcement reaches the volume only when the StorageClass carries
dhchap_node_label — the CSI driver then writes a matching nodeAffinity
onto the PV and a non-allowed node fails to mount. Without it the pool
reports DHCHAP enabled and any node mounts the volume, which is why the
disallowed-node assertion kept failing. Verified by hand on Talos and
RHCOS: allowed node mounts, non-allowed node gets
"MountVolume.NodeAffinity check failed".

- create_storage_class() takes dhchap_node_label; security tests derive
  simplyblock.io/pool.<ns>.<cluster CR>.<pool> and pass it on both the
  plain and crypto classes. Paired with Immediate binding and no
  allowedTopologies, which needs no CSI driver restart.
- Skip instead of fail when the host kernel ignores the DHCHAP connect
  options (no CONFIG_NVME_AUTH — Talos 6.18.24 does not have it, RHCOS
  9.6 does), matching the RDMA test's skip pattern.
- _k8s_verify_pod_scheduling cleans its pod in a finally and tracks it
  for teardown; teardown now deletes pods BEFORE PVCs and removes
  test-created StorageClasses. A leaked pod held pvc-protection on its
  claim and left three PVCs stuck Terminating for 3+ hours.

Pipeline cleanup (cleanup_upgrade_test.sh) missed three things that let
stale state survive into later runs:
- CR_TYPES had pool./simplyblockpool. but not storagepools., stale since
  the Pool->StoragePool rename, so pools were never deleted and the next
  run reused one.
- StorageClasses were filtered by name containing "simplyblock", which
  matched 1 of 53 leaked classes; now selected by provisioner.
- Only two node labels were stripped; simplyblock.io/storage-node-uuid.*
  and simplyblock.io/pool.* embed an id in the key so each deploy adds a
  new one. Left behind, the CSI driver advertises topology for clusters
  that no longer exist. Now stripped by prefix.
The operator derives simplyblock.io/pool.<ns>.<cluster>.<pool> from the
StoragePool CRD's metadata.name, while self.pool_name holds the backend
pool name. Those have matched in every run observed so far, but nothing
guarantees it — and a wrong key means the StorageClass silently carries
no enforcement, which is the exact failure mode this change set exists
to fix. Look the label up on an allowed node (value == "allowed", key
suffix == pool name) and only fall back to the computed string, warning
loudly when that happens.
Four deviations from the documented K8s DHCHAP flow now carry explicit
HACK comments saying what they work around and when to delete them:

1. We bypass the operator-generated StorageClass and hand-roll one with
   only dhchap_node_label, no allowedTopologies, Immediate binding —
   because the operator's class provisions nothing until the CSI driver
   re-registers.
2. spec.nodeName pinning is only sound because of (1); against the
   documented WaitForFirstConsumer class it is a false negative.
3. DHCHAP pools get a timestamp-suffixed name so a shared leftover pool
   cannot shadow them, since blind pool reuse is load-bearing for every
   non-DHCHAP caller.
4. The older security tests still register a host NQN by hand in K8s
   mode and pass every worker as allowed, contradicting the doc and
   making a disallowed-node rejection untestable there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants