Skip to content

fix(auth): survive a slow gcloud and a missing ssh-copy-id (0.22.1) - #180

Merged
johnyaku merged 4 commits into
mainfrom
fix/auth-setup-robustness
Aug 14, 2026
Merged

johnyaku merged 4 commits into
mainfrom
fix/auth-setup-robustness

Conversation

@johnyaku

@johnyaku johnyaku commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Three bugs found on a single dt auth setup run in a containerised module load dt environment, 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 gcloud was reported as "not authenticated"

gcloud auth list had a 10 s 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:

cold 20.4 s
warm 5.3 s

Blowing the budget returned the same bare None as "nobody is logged in", so the run advised gcloud auth login to someone who had just done exactly that and whose account was active.

Budget raised to 30 s, and the tri-state None replaced with gcloud_auth_status() returning missing / 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-id was 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 _deploy_key_forge directly above it which checks shutil.which. So FileNotFoundError escaped the whole SSH phase, and the caller's broad except Exception discarded 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_keys over plain ssh. Three details that matter:

  • The key travels as $0, never interpolated into the script body.
  • Both script and key are shlex.quote'd — ssh joins its arguments and the remote shell parses them a second time, so one layer of quoting isn't enough.
  • stdin is left alone, since the key isn't deployed yet and ssh may need to prompt for a password.

3. The GCP quota project was never set

secrets.gcp.project reached the secret resource path but never the credentials. Those are two different things called "the project", and nothing derived one from the other — so gcloud auth application-default login users got a google-auth UserWarning about "quota exceeded"/"API not enabled" on every call. Setting it once in dt beats every user running gcloud auth application-default set-quota-project on every machine they touch.

4. 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.

Every full-suite run through qxub reported 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 return False: 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_setup and setup._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.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_* — 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 the authorized_keys script executed for real against a throwaway HOME: 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_PATH is an unused import in setup.py. It was already unused on main, so it's left alone rather than widening this diff.

🤖 Generated with Claude Code

johnyaku and others added 4 commits August 14, 2026 15:56
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>
@johnyaku
johnyaku merged commit 2129ed6 into main Aug 14, 2026
1 check passed
@johnyaku
johnyaku deleted the fix/auth-setup-robustness branch August 14, 2026 10:52
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