Skip to content

fix(controller): stop a data-plane outage from revoking issued credentials - #41

Merged
duckhawk merged 1 commit into
mainfrom
fix/transient-outage-revokes-credentials
Jul 30, 2026
Merged

fix(controller): stop a data-plane outage from revoking issued credentials#41
duckhawk merged 1 commit into
mainfrom
fix/transient-outage-revokes-credentials

Conversation

@duckhawk

@duckhawk duckhawk commented Jul 29, 2026

Copy link
Copy Markdown
Member

Symptom

The e2e run on the current main fails system-durabilitykeeps every replica on its own volume and node across a full data-plane restart. Everything about the volumes is fine — same PVC → same PV → same node, pods back on their pinned nodes, the stored object still readable — and then the last assertion catches it:

[FAILED] a restart must not look like a recreate: the backend keys must still be there
Expected <string>: GK1e67171c989402cec63e48a3
to equal <string>: GK89d24858ab7875a1873da8eb

The access key issued to the caller is not the one it had before the restart.

Cause

The controller log, taken while the Garage pods were down, says it plainly — it was deleting the issued keys, retry after retry, for both accesses on the store:

Reconciler error err="delete access key: Post \"http://system-garage…:3903/v2/DeleteKey?id=GK89d24858ab7875a1873da8eb\": dial tcp …: connect: connection refused"
Reconciler error err="delete access key: Post \"http://system-garage…:3903/v2/DeleteKey?id=GKd1221fbd39536666c02ca893\": dial tcp …: connect: connection refused"

Not the delete path: both BucketAccess objects were alive and back to Ready afterwards. It is the gate in reconcileNormal, which treated three very different situations as one:

if !claimBound(claim) || bucket == nil || cluster == nil ||
   bucketReadyState(bucket) != True || !authorized {
        // revoke the backend key + delete the credentials Secret

A restarting store flips its Bucket out of Ready. That is a health signal, but the gate read it as "the claim is no longer usable" and revoked: backend key deleted, credentials Secret deleted, status.accessKeyID cleared. Once the store came back, EnsureAccess found no recorded key and minted a fresh one — so a caller's credentials were silently rotated because of an outage the controller only had to wait out. The bigger the store, the longer the window: every access on it is affected for as long as the data plane is down.

Fix

Revocation is driven by intent, not by health:

situation before now
bucket exists, not Ready (restart, backend unreachable, still coming up) revoke key + delete Secret wait: AccessGranted=False/InProgress, key and Secret untouched, requeue
claim no longer bound to a bucket revoke revoke (unchanged)
bound bucket / store gone revoke revoke (unchanged)
namespace no longer authorized by a BucketClaimPolicy revoke revoke (unchanged)

The deny-by-default enforcement is untouched: losing authorization still revokes on the spot, and it is still re-checked on every reconcile of a granted access.

Tests

bucket_access_revoke_test.go, three unit tests over a recording driver:

  • an unready bucket must not call the backend at all, must keep status.accessKeyID / status.secretRef and the credentials Secret, and must report False/InProgress — this one fails on the old gate exactly the way the cluster did (the backend key was revoked 1 time(s), status.accessKeyID = "");
  • a Shared bucket with no policy still revokes, with DeniedByPolicy;
  • a claim that is no longer Bound still revokes.

The system-durability e2e spec is the end-to-end check — it is what caught this.

Base

Found while running the e2e suite for the Commander registry_mode fix and unrelated to it, so it was originally stacked on #40 to get both into one e2e run. #40 is merged now, and this is a single commit rebased straight onto main — no dependency left, nothing to merge first.

Also in here

Two fixes to the test side, both found on the same run and squashed into the same commit:

  • the e2e pod dump could only say phase=Pending ready=false, so the run that lost its three-hour budget to two Pending Garage pods never printed the scheduler's own answer. It now carries the node and, for anything unplaced, the PodScheduled reason and message, plus a node dump with role, Ready, unschedulable and taints;
  • a Garage unit test dereferenced a Secret it had only checked with Errorf, so a missing node identity panicked instead of reporting what went wrong — staticcheck flags it, which is why the Go linter was red on one runner and green on another for the same tree. Split out as Fatalf.

@duckhawk
duckhawk force-pushed the fix/transient-outage-revokes-credentials branch from 7a35526 to 6674114 Compare July 29, 2026 12:52
@duckhawk
duckhawk changed the base branch from main to test/e2e-registry-mode-pin July 29, 2026 12:52
@duckhawk duckhawk added e2e/commander/run Trigger the storage-e2e commaner pipeline for this PR and removed e2e/commander/run Trigger the storage-e2e commaner pipeline for this PR labels Jul 29, 2026
@duckhawk
duckhawk force-pushed the fix/transient-outage-revokes-credentials branch from 6674114 to c508f18 Compare July 29, 2026 13:31
@duckhawk duckhawk added e2e/commander/run Trigger the storage-e2e commaner pipeline for this PR and removed e2e/commander/run Trigger the storage-e2e commaner pipeline for this PR labels Jul 29, 2026
@duckhawk
duckhawk force-pushed the fix/transient-outage-revokes-credentials branch from c508f18 to 9a8c585 Compare July 30, 2026 02:00
@duckhawk duckhawk added e2e/commander/run Trigger the storage-e2e commaner pipeline for this PR and removed e2e/commander/run Trigger the storage-e2e commaner pipeline for this PR labels Jul 30, 2026
@duckhawk
duckhawk force-pushed the test/e2e-registry-mode-pin branch from 038bbec to 8fbafae Compare July 30, 2026 16:52
@duckhawk
duckhawk force-pushed the fix/transient-outage-revokes-credentials branch from 930b949 to de56db1 Compare July 30, 2026 16:52
Base automatically changed from test/e2e-registry-mode-pin to main July 30, 2026 16:55
…tials

The e2e run on the current main fails system-durability ("keeps every replica on
its own volume and node across a full data-plane restart"): after the restart the
BucketAccess reports a different access key than before —

    a restart must not look like a recreate: the backend keys must still be there
    Expected <string>: GK1e67171c989402cec63e48a3
    to equal <string>: GK89d24858ab7875a1873da8eb

and the controller log shows why. While the Garage pods were down it was calling
DeleteKey for every issued access, over and over:

    Reconciler error err="delete access key: Post \".../v2/DeleteKey?id=GK89d2…\":
    dial tcp …:3903: connect: connection refused"

Not the delete path: the accesses were alive and Ready again afterwards. It is the
gate in reconcileNormal, which lumped `bucket is not Ready` together with `claim is
no longer bound` and `namespace is no longer authorized` and revoked on all three.
A restarting store flips its Bucket out of Ready, so every access on it lost its
backend key and its credentials Secret; once the store came back, EnsureAccess
found the recorded key gone and minted a fresh one. The caller's credentials were
silently rotated because of an outage the controller only had to wait out.

Revocation is now driven by intent, not by health. A bucket that exists but is not
Ready reports AccessGranted=False/InProgress and requeues, leaving the grant and
the Secret untouched. The claim losing its binding, the bucket or store being gone,
and the namespace losing its authorization all still revoke immediately — the
deny-by-default enforcement is unchanged.

Tested: an unready bucket must not touch the backend, the status or the Secret (the
test fails on the old gate exactly the way the cluster did); a withdrawn policy must
still revoke with DeniedByPolicy; an unbound claim must still revoke.

Two fixes to the test side come along, both found on the same run:

The e2e pod dump could only say `phase=Pending ready=false`, so the run that lost
its three-hour budget to two Pending Garage pods — the System store's third replica
and a brand-new Lightweight store's first pod — never printed the scheduler's own
answer, the node the pod did or did not land on, or the cluster's node list. The
dump now carries the node and, for anything unplaced, the PodScheduled reason and
message; a node dump lists role, Ready (with reason when it is not), unschedulable
and taints. The control-plane count in particular drives the System profile's
placement, so a resize that half-succeeded is now visible directly.

And a Garage unit test dereferenced a Secret it had only checked with Errorf, so a
missing node identity panicked instead of reporting what went wrong — staticcheck
flags it, which surfaced as a red Go linter on one runner and a green one on
another for the same tree. Split the nil case out as Fatalf.

Signed-off-by: v.oleynikov <vasily.oleynikov@flant.com>
@duckhawk
duckhawk force-pushed the fix/transient-outage-revokes-credentials branch from de56db1 to dc3a633 Compare July 30, 2026 16:57
@duckhawk
duckhawk merged commit 1816fd3 into main Jul 30, 2026
10 checks passed
@duckhawk
duckhawk deleted the fix/transient-outage-revokes-credentials branch July 30, 2026 16:58
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.

1 participant