Prove on a kind cluster that a rotated Secret reaches the operator - #248
Merged
Merged
Conversation
Every earlier step stopped short of the end: rendered manifests, the gate against a hand-built layout, the kubelet on its own. This job runs the whole path. The gateway is installed from charts/nkap with its M-Pesa credentials mounted as files, beside PostgreSQL and the M-Pesa simulator, all built from the checkout. A payment is submitted, the Secret is patched with a new passkey, Consumer Key and Consumer Secret, and the job checks what the simulator received. It checks that the Password is recomputed from the new passkey, that the token request carries the new Consumer Key and Secret, and that the staleness gauge stays at zero with no restart. It waits on conditions, never on sleeps, and every wait fails naming what it waited for. No credential reaches the log: values are extracted, compared and never echoed, and a failure names the field. The Consumer Key is asserted only after one further submission, so the in-flight token refresh ADR 0015 describes cannot make it flaky. It has its own workflow because it takes minutes. It runs on main, by hand, and on pull requests that touch the chart, the credential code, the M-Pesa adapter or the simulator. With the re-read gate made blind it fails: 300s after the patch, the gateway still sends a Password that is not computed from the new passkey. Signed-off-by: Devalère <28451130+Deval123@users.noreply.github.com>
ADR 0015's amendment, the changelog, the security notes, the chart README and two javadocs said a Kubernetes Secret update leaves the mounted file's modification time unchanged, so a gate on the time alone never fired. That measurement ran `stat -c %Y` on the mounted path, which is a symbolic link, and without -L it reports the link's own time. The gateway reads the time through the link, and the file it resolves to is newly written by every update. Measured on kind, Kubernetes v1.35.0 and v1.37.0: the link's time stays the same and the resolved file's time changes. The end-to-end job confirms it. Run with the stamp reverted to the modification time alone, it passed. The gate on real path, time and size stays: it is correct and costs nothing. What changes is the stated reason, and "about 2 seconds", since the clusters measured here took 55 to 87 seconds. Everything corrected is unreleased. Signed-off-by: Devalère <28451130+Deval123@users.noreply.github.com>
The amendment accounted for 55 to 87 seconds by the kubelet's sync period plus its Secret cache lifetime. That mechanism is doubtful: the kubelet's default change detection for Secrets is a watch, which would propagate in seconds. A plausible explanation beside a correct measurement is the kind of sentence that later reads as fact. What is known is only this: two measurements of the same quantity, on the same Kubernetes version, disagree by about thirty times, and nothing explains it. Neither number is Kubernetes', so the amendment now says that and tells a reader to measure their own cluster. Signed-off-by: Devalère <28451130+Deval123@users.noreply.github.com>
The rotation job's timeout comment and the chart README explained the Secret's 55-to-87-second delay by the kubelet's sync period and cache lifetime. Nobody measured that, and the kubelet's default change detection is a watch, which argues against it. A comment that justifies a number is read by whoever next doubts the number. It must not hand them a mechanism as if it were known. The timeout stays at five minutes, on the only ground there is: it covers every delay observed, with margin. The chart README keeps what was measured. A pod with no gateway saw the file change as late, so the delay is not the gateway's, and why it varies is not known. Signed-off-by: Devalère <28451130+Deval123@users.noreply.github.com>
Security notes §1 summarised the Secret's propagation delay as about a minute. That is a fair summary and names no cause, but in a security note it is read as a ceiling by someone replacing a compromised passkey, and nothing establishes one. The delay has been observed between about 2 and 87 seconds across runs, with no bound and no explanation. The paragraph now says so, and draws the consequence it exists for: a rotation that must take effect by a deadline is a restart, not a wait. Signed-off-by: Devalère <28451130+Deval123@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An end-to-end job on a
kindcluster: the gateway installed fromcharts/nkapwith its M-Pesa credentials mounted as files, a patched Secret, and a check of what the M-Pesa simulator received. For #240. It does not sayCloses, because the acceptance criterion the plan set could not be met as written, for a reason that is a finding in itself (below). Whether #240 is done is the maintainer's call.The headline: the job passes with the modification-time fix reverted, because that fix closed no defect
The plan's acceptance criterion was: revert
CredentialFileReader.Stampto a modification time alone, run the job, watch it fail. I did that, and it passed. I checked that the image under test really held the reverted code:javapon itsCredentialFileReader$CredentialFileshowsFiles.getLastModifiedTimeon the link path, a constant size, and notoRealPath.The reason: the measurement behind the earlier diagnosis ran
stat -c %Yon the mounted path. That path is a symbolic link, andstatwithout-Lreports the link's own time. The gateway never read that time.Files.getLastModifiedTimefollows links, and the file a link resolves to is newly written by every Secret update. I measured both onkind:stat -c %Y)stat -L -c %Y)v1.35.0(the version of the original measurement)1790372232→17903722321790372232→1790372300v1.37.01790372057→17903720571790372057→1790372149So the gate that compared the time alone did see a Kubernetes Secret update on both clusters. What the diagnosis described, a rotated credential never re-read, did not happen on either. The current gate (real path, time and size) is correct and stays. The real path is the more direct signal, and it costs nothing. But the reason recorded for it was wrong, and the second commit corrects that record everywhere it appears. All of it is
[Unreleased], so no released version is involved.What failing looks like
The job does catch a re-read that is actually broken. With the gate made blind, a stamp that never changes (the behaviour the diagnosis believed Kubernetes caused), it fails:
It then dumps pods, events, gateway, key-init (key masked) and simulator logs. No credential appears in any of the logs from my runs (grepped for every generated value and for the API key pattern: 0 hits).
The job
.github/scripts/kind-credential-rotation.sh: runs the same way in CI and by hand. It creates and deletes its own cluster (KEEP=1keeps it). It builds the gateway and simulator from the checkout and loads them into the cluster; it never pulls a published image. PostgreSQL is a plain Deployment. The simulator is the published-image Dockerfile's build. The gateway comes fromhelm installwith the defaultcredentialsAs: files.base64(BusinessShortCode + passkey + Timestamp)from the record's own values, and assert the baseline with the first passkey.Passwordmatches the new passkey (5-minute bound).nkap_credentials_stale_secondsfrom the management port before, during and after, and require it to be present and zero.sleepis the poll interval inside such a wait. Noset -x. Credentials are compared in variables, and a failure names the field, not the value..github/workflows/credential-rotation.yml: its own workflow, since it takes minutes andbuild.yml's jobs run on every push. It runs onmain, onworkflow_dispatch, and on pull requests touchingcharts/**, the credential reading and re-read code, the staleness gauge,application.yml,provider-mpesa/src/main/**,simulator-mpesa*/**,simulator-core/**, and itself. The job has atimeout-minutes: 25.Measured, and assumed
kindv0.33.0 and Kubernetesv1.37.0, which is whatkindgives by default:mvn -B clean verifyis green on the committed tree.v1.35.0) and 87s (v1.37.0) in the bare probes. The earlier measurement found about 2 seconds on the same Kubernetes version (v1.35.0), roughly thirty times less, and the difference is unexplained: ADR 0015's amendment records it as such and says to measure one's own cluster. The 5-minute bound covers every delay observed, with margin for a slower cluster; it rests on no mechanism.rotationcheck: it passed in 2m51s on Kubernetesv1.35.0, with the Secret reaching the pod 55s after the patch. The public log contains none of the generated credentials and no API key (0 hits).CHANGELOG
No new entry: nothing a deployer runs changes. Three existing
[Unreleased]entries are corrected, because they told a deployer that a Secret update leaves the time alone and that the whole path was unmeasured. Both statements are now false.Where the plan was wrong
publicBaseUrl,provider.default: mpesa-ke, and the key fromkubectl logs job/<release>-nkap-key-init. Nothing was missing. Its only error was the timing claim above, now corrected. Two details the README states and the job depends on: the key-init Job does not wait for PostgreSQL, so the job has PostgreSQL ready beforehelm install, and the management port is on no Service, so the job port-forwards to the pod.POST /paymentsanswers201when M-Pesa accepts the submission, not202; the job accepts either.