Repository navigation
fix(auth): survive a slow gcloud and a missing ssh-copy-id (0.22.1) - #180
Merged
Merged
Conversation
Three bugs found on one `dt auth setup` run in a containerised `module load dt` environment. Each made a working setup look like a broken one, and two of them sent the user off to fix something that was never wrong. **gcloud timeout reported as "not authenticated".** `gcloud auth list` had a 10s budget. gcloud is a large Python application and on NCI it lives on /g/data, so a cold start pays NFS latency for every module it imports -- measured 20.4s cold, 5.3s warm on a Gadi persistent-session node. Blowing the budget returned the same bare None as "nobody is logged in", so the run advised `gcloud auth login` to a user who had just done exactly that, and whose account was in fact active. Raises the budget to 30s and replaces the tri-state None with gcloud_auth_status(), which distinguishes missing / timeout / error / unauthenticated. Only the last one now suggests re-authenticating. check_gcloud_authenticated() stays as the yes/no wrapper. **ssh-copy-id assumed to exist.** It ships in openssh-clients as a separate file from ssh itself, so minimal container images routinely have one and not the other -- which is what the containerised dt build hits. The call was unguarded (unlike the forge path directly above it, which checks shutil.which), so FileNotFoundError escaped the entire SSH phase. The caller's broad `except Exception` then discarded every result, including hosts that had already succeeded: the run had just registered a key with GitHub and reported nothing about it. Guards with shutil.which and falls back to appending to the remote authorized_keys over plain ssh. The key travels as $0 rather than being interpolated, and both the script and the key are shlex.quote'd, because ssh joins its arguments and the remote shell parses them a second time. stdin is left alone so ssh can still prompt for a password. Deployment is also now wrapped per host in both copies of the loop, so one failure cannot abandon the rest. **GCP quota project never set.** secrets.gcp.project reached the secret resource path but never the credentials, so `gcloud auth application-default login` users got a google-auth UserWarning about "quota exceeded"/"API not enabled" on every call. Attaching it once in dt beats every user running `gcloud auth application-default set-quota-project` on every machine. Also fixes a display bug: the "N host(s) need key deployment" list printed before the refinement pass that adds more hosts, so it announced a subset and then deployed to hosts it had never named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups to the auth fixes in this branch. **TestFetchUrlImport made a real network call.** Both tests patched subprocess.run, but `_fetch_url_import` consults `doctor.check_network_connectivity` first for any http/https/s3/gs source -- a socket probe, which a subprocess patch cannot touch. So they passed on a login node and failed on a compute node, where there is no internet. Every full-suite run through qxub reported 2 failures that had nothing to do with whatever was being changed, which is the fastest way to teach people to ignore a red test suite. Mocked in both tests. Verified by forcing the ambient probe to return False: the tests' own mock wins, so they no longer depend on where they run. **The key-deployment loop existed twice**, byte-identical, in ssh.ssh_setup and setup._do_ssh_setup. They had already drifted -- one carried a verbose "could not deploy" message the other lacked -- and the earlier fix in this branch had to be applied to both by hand, which is the argument for not leaving it that way. Extracted to ssh._deploy_keys_and_report, called by both. setup.py loses 66 lines and no longer imports the deploy helpers at all; a test asserts they stay gone, since their return would mean the duplication returned with them. The existing suite already patched `dt.auth.ssh._deploy_key_*`, which is where the shared function resolves them, so those tests now cover both entry points rather than one. Full suite under PBS: 2138 passed, 2 skipped, 0 failed -- the first fully green compute-node run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setting a quota project asks GCP to bill it, which requires serviceusage.services.use on that project. A user who can read the secret but lacks that permission gets USER_PROJECT_DENIED -- a 403, so PermissionDenied -- on a call that would have succeeded without the header. That is not fatal today, because get_raw_config already falls back to the gcloud CLI on PermissionDenied and the CLI sends no quota header. But nothing recorded that the new header depends on that fallback, so a future tightening of the exception handling would break it silently. Pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two message bugs that between them cost most of a day of debugging, both
found while chasing a `secretmanager.versions.access` denial that turned out
not to be a permission problem at all.
**PERMISSION_DENIED does not mean "you lack permission".** Secret Manager
returns it for a secret you cannot see *and* for one that is not in the
project -- deliberately, since a denial that differed from a not-found would
let anyone probe which secrets exist. dt repeated GCP's framing verbatim
("Ensure you have secretmanager.versions.access permission") and never said
which project it had asked. So the grant was made, repeatedly, against a
project that did not hold the secret, and each failure looked like the grant
had not propagated yet.
The message now names the project, says the secret may simply not be there,
and prints the two commands that tell them apart: `dt config get
secrets.gcp.project` and `gcloud secrets list --project=...`. That matters
because secrets.gcp.project resolves through system, user and repo scope, so
a personal override silently wins over the group config -- two people on one
machine can query different projects and neither can see why.
**A 403 was reported as a missing package.** The Python client raised
PermissionDenied, the CLI fallback found no gcloud on the container's PATH,
and only the second failure survived: "Neither the google-cloud-secret-manager
Python package nor the gcloud CLI is available." The package was installed.
The user was sent to install software they already had, while the real error
was discarded.
_require_gcloud now takes the originating exception and reports both -- what
actually failed, and why the fallback could not be tried. With no prior
failure the old install advice is unchanged.
Full suite under PBS: 2145 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.
Three bugs found on a single
dt auth setuprun in a containerisedmodule load dtenvironment, plus two follow-ups. Each of the three made a working setup look broken, and two sent the user off to fix something that was never wrong.1. A slow
gcloudwas reported as "not authenticated"gcloud auth listhad a 10 s budget.gcloudis a large Python application and on NCI it lives on/g/data, so a cold start pays NFS latency for every module it imports:Blowing the budget returned the same bare
Noneas "nobody is logged in", so the run advisedgcloud auth loginto someone who had just done exactly that and whose account was active.Budget raised to 30 s, and the tri-state
Nonereplaced withgcloud_auth_status()returningmissing/timeout/error/unauthenticated/ok. Only the last failure now suggests re-authenticating.check_gcloud_authenticated()stays as the yes/no wrapper for callers that don't show the answer to a user.2.
ssh-copy-idwas assumed to existIt ships in
openssh-clientsas a separate file fromsshitself, so minimal container images routinely have one and not the other — which is what the containerised dt build hits.The call was unguarded, unlike
_deploy_key_forgedirectly above it which checksshutil.which. SoFileNotFoundErrorescaped the whole SSH phase, and the caller's broadexcept Exceptiondiscarded every result — including hosts that had already succeeded. The failing run had just registered a key with GitHub and reported nothing about it.Now guarded, with a fallback that appends to the remote
authorized_keysover plainssh. Three details that matter:$0, never interpolated into the script body.shlex.quote'd —sshjoins its arguments and the remote shell parses them a second time, so one layer of quoting isn't enough.stdinis left alone, since the key isn't deployed yet andsshmay need to prompt for a password.3. The GCP quota project was never set
secrets.gcp.projectreached the secret resource path but never the credentials. Those are two different things called "the project", and nothing derived one from the other — sogcloud auth application-default loginusers got a google-authUserWarningabout "quota exceeded"/"API not enabled" on every call. Setting it once in dt beats every user runninggcloud auth application-default set-quota-projecton every machine they touch.4.
TestFetchUrlImportmade a real network callBoth tests patched
subprocess.run, but_fetch_url_importconsultsdoctor.check_network_connectivityfirst for anyhttp/https/s3/gssource — a socket probe, which a subprocess patch cannot touch. So they passed on a login node and failed on a compute node.Every full-suite run through
qxubreported 2 failures unrelated to whatever was being changed, which is the fastest way to teach people to ignore a red suite. Verified by forcing the ambient probe to returnFalse: the tests' own mock wins, so they no longer depend on where they run.5. The key-deployment loop existed twice
Byte-identical copies in
ssh.ssh_setupandsetup._do_ssh_setup. They had already drifted — one carried a verbose "could not deploy" message the other lacked — and fix #2 above had to be applied to both by hand, which is the argument for not leaving it that way.Extracted to
ssh._deploy_keys_and_report.setup.pyloses 66 lines and no longer imports the deploy helpers at all; a test asserts they stay gone, since their return would mean the duplication returned with them. The existing suite already patcheddt.auth.ssh._deploy_key_*— where the shared function resolves them — so those tests now cover both entry points rather than one.Also
The "N host(s) need key deployment" list printed before the refinement pass that adds more hosts, so it announced a subset and then deployed to hosts it had never named — which reads as the connectivity check being wrong.
Tests
2138 passed, 2 skipped, 0 failed under PBS via
qxub— the first fully green compute-node run.35 new tests in
tests/unit/test_auth_robustness.py, including theauthorized_keysscript executed for real against a throwawayHOME: it round-trips a key containing",$, and backticks through both shell layers, is idempotent, sets 600/700, and preserves pre-existing entries.Note
_DEFAULT_KEY_PATHis an unused import insetup.py. It was already unused onmain, so it's left alone rather than widening this diff.🤖 Generated with Claude Code