fix(none): Databricks Volumes check reads the configured volume - #816
paulkarayan wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="test/unit/connectors/databricks/test_volumes.py">
<violation number="1" location="test/unit/connectors/databricks/test_volumes.py:302">
P2: This assertion does not prove that the probe is placed at the volume root because any descendant path also matches it. Include the required `unstructured_precheck_` filename prefix in the expected prefix so a nested-path regression fails.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| _uploader(mocker, client, volume_path="").precheck() | ||
|
|
||
| probe_path = client.files.upload.call_args.kwargs["file_path"] | ||
| assert probe_path.startswith("/Volumes/catalog/schema/volume/") |
There was a problem hiding this comment.
P2: This assertion does not prove that the probe is placed at the volume root because any descendant path also matches it. Include the required unstructured_precheck_ filename prefix in the expected prefix so a nested-path regression fails.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/unit/connectors/databricks/test_volumes.py, line 302:
<comment>This assertion does not prove that the probe is placed at the volume root because any descendant path also matches it. Include the required `unstructured_precheck_` filename prefix in the expected prefix so a nested-path regression fails.</comment>
<file context>
@@ -200,3 +226,172 @@ def test_indexed_file_reports_modification_time_in_epoch_seconds(mocker: MockerF
+ _uploader(mocker, client, volume_path="").precheck()
+
+ probe_path = client.files.upload.call_args.kwargs["file_path"]
+ assert probe_path.startswith("/Volumes/catalog/schema/volume/")
+
+
</file context>
| assert probe_path.startswith("/Volumes/catalog/schema/volume/") | |
| assert probe_path.startswith("/Volumes/catalog/schema/volume/unstructured_precheck_") |
|
The error-classification fix looks right to me. I checked each of the SDK's error-code subclasses against the locked SDK and they all map to their parent status. Two things before this merges, though. The first is why I'm holding it. The precheck shouldn't write to the customer's volume
Unity Catalog can answer the same question without a write.
Before this change, an unmapped SDK subclass left the plugin as a 500, and the platform retries a 500. With the MRO walk, the 400 and 409 subclasses become |
… DDL (#818) ## What & why **Problem:** A Teradata destination user who cannot create the table the job auto-creates still finds that out at upload time rather than at the connector check, because the check asks a narrower question than the upload does. The probe that shipped in 1.11.18 creates a one-INTEGER-column table, while `create_destination()` runs the connector's real DDL with `CLOB`, `VECTOR32`, `JSON` and a `PRIMARY INDEX`. A right one of those column types needs and CREATE TABLE alone does not carry is invisible to the check, so the credential passes and the job fails partway through. Separately, a user whose per-workflow table already exists and who has since lost CREATE TABLE is now refused by that check with no way to act on the message, even though the upload would have found the table and never created one. **Change:** The probe runs `create_destination()`'s own statement under the throwaway name, unqualified, so the rights it asks for are the rights the upload needs. Teradata 3523 refuses alongside 3524, with a message that does not claim CREATE TABLE is the missing right. Blank Database and unset Table Name each add a sentence naming the field that settles the refusal, so the customer has something to do. A refusal the server already gave survives a failure tearing the session down, and each precheck outcome gets its own log line. **Blast radius:** 3/5 -- the connector check runs the real destination DDL on the customer's Teradata; one connector, revert-safe. ## Linked ticket none Follow-up to #815, which merged before this work landed. This branch closes Trevor's review items **2, 3, 4, 5, 6 (traceability), 7, 10, 11a and 11b** from #815 (comment), and the code half of item **1**. Items **9** and the body half of **1** were fixed by editing the merged body of #815. Item **8** (version collision) and item **11c** (`TableKind = 'T'`) are answered in that thread and not changed here; the reasoning is in #815 (comment). **Provenance, so the diff is not mis-read.** The first commit here, `7e6f164a` "probe Teradata CREATE with the real destination DDL", was written in a separate earlier session against the branch of #815 and was still unpushed when that PR merged. It is not new work and it is not mine; this branch carries it forward unchanged, cherry-picked onto `main` (the trees matched exactly, so it applied clean). The second commit is the new work: the Table Name hint, its tests, and the CHANGELOG restructure. Client-facing follow-up: a customer configured a Teradata destination with a non-admin user and a blank Database field; it passed the connector check and the run auto-created its table in a database the user had not chosen. ## Impact - **Customers:** Teradata destination users whose table is auto-created are now refused for the rights the real CREATE needs, not just CREATE TABLE on the database: a `VECTOR32` column refused UDTUSAGE on `SYSUDTLIB` comes back as 3523 and is caught here instead of failing the job. Refusal messages become actionable rather than dead ends. With the Database field blank the message says the database it names is only the session default, so a DBA does not grant rights on a database nobody chose. With no Table Name configured the message says the check had to create a table to test the right, and that setting Table Name makes the check look the table up instead. That is the escape hatch for the one case this check can refuse a credential the job would not have needed. Destinations with a configured, existing table are unchanged: they still get the 1.11.16 INSERT/DELETE probe. - **Internal (devs / ops / other teams):** support gets one INFO line per precheck outcome (skipped, refused, created, inconclusive) instead of a single line that could not distinguish "probe passed" from "skipped, table exists", and `create_destination()` now names the database on the line that actually creates the table. The probe table is named in a log line *before* the CREATE runs, so a pod killed between CREATE and DROP leaves a traceable name. No other connector is touched; `_write_denied_message` is called with the same arguments 1.11.18 introduced. - **Wire contract / clients:** the precheck `UserError` (422) messages gain trailing sentences (the blank-Database hint from 1.11.18, plus the new Table Name hint). Teradata **3523 is a new refusal code** for the destination precheck: a credential that previously passed the CREATE probe and failed at upload now gets a 422 at check time. `test_teradata.py:1329` pins the upload-path wording and is unchanged. Nothing outside the `teradata` connector changes; the shared `_USER_FAULT_TERADATA_CODES` map is not edited by this branch. - **Deployment target considerations:** the same on every target, because this is library code in the connector rather than anything deployed on its own. It runs wherever a Teradata destination precheck runs, which is per job where the preflight gate is enabled and on every UI test-connection. Not verified on any target with a real Teradata (see Proof). ## Risk / rollback - The probe now runs the real DDL, which is a larger statement than the one-column stand-in. It still creates and drops one table and commits under the driver's autocommit, so the leak window is unchanged: two statements. A failed DROP still passes the check and names the leftover. - 3523 refusing is the one genuinely wider behaviour here. It is bounded to the CREATE probe's own `except`, and 3523 means a refused right and nothing else, so it cannot turn a missing table into a permissions error. - No sweep of leftover probe tables was added, deliberately: bounded wrong it can drop a concurrent precheck's in-flight probe under the same credential. - Revert the PR to back it out; nothing persists, no migration. ## How it was verified - `make test-unit`: 1843 passed. The teradata module alone is 168, up from 166, the two new ones being the Table Name hint firing when the field is unset and staying off when it is set. - `make check` (`ruff check .`): all checks passed. `ruff format` was deliberately **not** run: the file was already format-dirty at `HEAD` before any edit here (verified with `git show HEAD:<file> | ruff format --check`), 17 files repo-wide fail `ruff format --check`, and CI's lint job runs `ruff check` only. Reformatting would have been unrelated churn on a review diff. - The integration test asserts the precheck leaves no `unstructured_precheck_%` table behind, with `_` escaped in the LIKE so the prefix cannot match by accident. It is gated on `TERADATA_*` credentials and skipped without them, so it did not run here. - NOT verified against a live Teradata. The 3523 classification comes from Teradata's Database Messages manual and the existing `_USER_FAULT_TERADATA_CODES`, not from a server. ## Proof > **Proof waived (environment)** -- no live Teradata is reachable from this machine. There are no `TERADATA_*` credentials locally and no Vantage SQL listener answers here. Unblocked by a Teradata Vantage (a ClearScape trial works) with an admin who can create a user lacking CREATE TABLE in one database, and a second lacking UDTUSAGE on `SYSUDTLIB`; then run the CREATE-probe red/green for both 3524 and 3523 against `main` and this branch, and land the restricted-user integration test modelled on `test_postgres_destination_precheck_refuses_a_credential_that_cannot_write` (`test_postgres.py:247`). That test is owed and is not in this branch. Unit-level repro-first record for the new behaviour in this branch (the Table Name hint): **Red** -- test written before the fix, against this branch's first commit: ``` $ uv run --locked --no-sync pytest -q test/unit/connectors/sql/test_teradata.py -k table_name_field E assert 'No Table Name is configured' in "The destination credentials can connect to the database but do not have CREATE TABLE permission on database 'test_db'. Records would fail to write. Grant CREATE TABLE on that database to the user this connector authenticates as." FAILED test_teradata_precheck_refusal_points_at_the_table_name_field_when_it_is_unset 1 failed, 167 deselected in 0.41s ``` **Green** -- after adding `_unset_table_hint()` and appending it to both refusal messages: ``` $ uv run --locked --no-sync pytest -q test/unit/connectors/sql/test_teradata.py 168 passed in 0.64s $ make test-unit 1843 passed, 1 warning in 37.62s $ make check uv run --locked --no-sync ruff check . All checks passed! ``` The second test in that pair (`..._omits_the_table_name_hint_when_one_is_configured`) passes both before and after by construction; it is there to pin that the hint stays off for a customer who already set the field. ## Dependencies / merge order none Note for whoever merges: this takes **1.11.20**, renumbered from 1.11.19 when #814 shipped that version on main. #816 is still at 1.11.17 while `main` is at 1.11.19, and is currently conflicting. `scripts/version-sync.sh:125` only rejects a version *equal* to main's, so a resolution there that keeps 1.11.17 goes backwards and CI will not catch it. Whichever of these two lands second needs its version re-checked by hand, not just its conflict resolved. --- <sub>Generated from Orca worktree `pk-teradata-precheck-followup` (branch `pk/teradata-precheck-review-followup`).</sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/Unstructured-IO/unstructured-ingest/pull/818?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: paulkarayan <pk@unstructured.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…write access The Volumes uploader precheck asserted `current_user.me().active`. That proves the token authenticates and the principal is enabled, and nothing else: it never read `upload_config.path`. So a volume that does not exist, a path typo, a principal with no WRITE VOLUME grant, and a catalog or schema the principal cannot USE all reported a green connector, and the run then failed at `files.upload` -- after the customer had already paid to partition, enrich and embed every file in it. precheck now does what the indexer has done since PLU-637: `me()` for the credentials and the host, then a probe at the configured path. The probe is the write itself, a zero-byte `unstructured_precheck_<hex>` file through the same `files.upload` the run uses, so it collects the same verdict. Unlike the fsspec uploader, which leaves its `_empty` marker behind forever, this deletes the probe: a Unity Catalog volume is routinely read back by a table or another job, so a stray file there is not inert. WRITE VOLUME already covers the delete, so cleanup asks for no grant the run does not need, and a failed cleanup warns rather than failing a check whose question has already been answered. Only a 401, 403 or 404 fails the check. A throttle, a Databricks-side 5xx, or an exception the SDK status mapping does not recognise is logged and allowed through, so no destination that works today starts failing on a check that was never meant to answer that question. The error names the volume path, which is the one thing the old check hid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HTTP status The SDK raises the error-code class (ResourceDoesNotExist) in preference to the status class (NotFound), and both _databricks_status_code and wrap_error matched the exact type, so a missing volume reached the write probe as "not a permission answer" and passed. Walk the MRO, and route wrap_error through the same helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The destination precheck proved write access by writing: a zero-byte probe file at the configured path, deleted again in a second call. A Unity Catalog volume is routinely watched, and an Auto Loader stream or a file-arrival trigger without a glob filter cannot tell that probe from real input during the window before the delete -- or ever, if the delete fails. Unity Catalog answers the same question by reading. volumes.read on the three-level name covers the missing catalog, schema or volume; grants .get_effective covers WRITE VOLUME for this principal; get_directory_metadata covers the configured volume_path. Nothing is written and nothing is removed. Only one thing fails the check now: Unity Catalog saying the volume does not exist, and only when the grant lookup did not already resolve that same volume. Everything else warns and passes, which is narrower than it looks: WRITE VOLUME does not imply READ VOLUME, so a 403 on a read is not a write denial, and the effective-permissions API is documented to expand parent securables and says nothing about group membership, so a WRITE_VOLUME missing from its answer may still be held through a group. me() is now filtered the same way as the rest: a 429, a 5xx or a socket error on the identity call warns and passes instead of aborting the check, while a 401, 403 or 404 still refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Walking the MRO gave the SDK's error-code subclasses their parent status, which also pulled the 409 subclasses into the 400-499 branch and made them UserError. The platform treats a non-429 4xx as terminal, so Aborted stopped being retried. Aborted is Databricks losing a race with itself -- two writers on one path, a sequencer check that did not hold -- not the customer's input and not our bug, and a retry is usually what clears it. It is classified as ProviderError, which keeps it retryable; the other 409 subclasses are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… they should The two warn-and-pass cases asserted only that precheck did not raise, which also passes if the check silently did nothing. Assert what each one proves: an identity call with no answer stops before the Unity Catalog reads, and a volume read that could not answer was really attempted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e727263 to
d547916
Compare
|
Taken, on both. The write probe is gone; the precheck reads Unity Catalog now, and nothing is created or removed in the volume. I checked your three calls against the locked SDK (0.88.0) rather than the docs, and all three are there:
On the unknown you flagged: yes, a 403 there is warn-and-pass. The answer turned out to be broader than the grants call, though. Databricks' privilege reference gives WRITE VOLUME as "add, modify, or delete files inside a volume" and does not make READ VOLUME a prerequisite the way WRITE FILES requires READ FILES on external locations. So a principal that can write here can be refused any of the reads, and a 403 from any of them is not a write denial. All three treat it as a warning. That leaves the refusal surface narrower than the probe's, and I would rather say so than paper over it:
If a live workspace later shows that On Also fixed the two cubic P2s: a 429 or 5xx on |
What & why
Problem: Customers setting up a Databricks Volumes destination got a green connection test for a volume they could not write to, and found out when the job failed at the upload node, after every file had already been partitioned, enriched, embedded and paid for. The check asked whether the principal was awake (
current_user.me().active) and never whether it could write, so a volume that doesn't exist, a typo in the path, a principal withoutWRITE VOLUME, or a catalog or schema it can'tUSEall passed.Change: The uploader's precheck now writes a zero-byte
unstructured_precheck_<hex>file at the configured volume path through the samefiles.uploadthe run uses, then deletes it. Only a 401, 403 or 404 fails the check, and the error names the volume path. Databricks SDK errors are also classified by their HTTP status even when the SDK raises a more specific subclass, which the missing-volume case depends on.Blast radius: 3/5 -- one connector's precheck, but it now writes (and deletes) a real file in the customer's volume, and the shared
wrap_errorclassification changes for every Databricks Volumes connector.Linked ticket
none. The source side of this connector got the equivalent fix in #794.
Why a write probe, and why only three status codes
current_user.me()proves the token and the host. It never readsupload_config.path, so it can't see anything Unity Catalog decides per object. The only call that collects the same verdict as the run is the run's own call, so the probe isfiles.uploadat the configured path withoverwrite=True, and it goes through the same client.The fatal set is deliberately narrow. 403 means "you may not write here", 404 means the catalog, schema or volume doesn't exist, and 401 means the workspace rejected the token. A throttle, a Databricks-side 5xx, a dropped socket or an exception the SDK doesn't map is not an answer to the permission question, so it logs a warning and passes: no destination that works today starts failing its connection test.
The probe is deleted afterwards because a Unity Catalog volume is routinely read back by a table, an Auto Loader stream or another ingest job.
WRITE VOLUMEalready covers the delete. If the delete fails anyway, the write question has already been answered, so it warns and names the file it left behind rather than failing the check.me()still runs first, so a rejected token fails there with no write attempted. Itsactiveflag is no longer asserted, which matches the source check; the write probe answers the question that flag stood in for.The SDK subclass fix
The Databricks SDK raises the error-code class in preference to the status class, so a 404 carrying
RESOURCE_DOES_NOT_EXISTarrives asResourceDoesNotExist, a subclass ofNotFound. Both the probe's status lookup andwrap_errormatched the exact type, so that error read as "not a permission answer" and the missing-volume case this PR exists to catch passed anyway. The lookup now walks the MRO, andwrap_errorgoes through the same helper. The helper change isn't optional: without it the probe's fatal branch would get the raw SDK error back fromwrap_errorand re-raise its unredacted text.Impact
wrap_erroris shared by the native, AWS, Azure and GCP Volumes connectors at precheck and run time on both source and destination; SDK error-code subclasses (ResourceDoesNotExist,InvalidParameterValue,RequestLimitExceeded,DataLossand the rest) that used to escape as raw, unclassified errors now becomeUserError,RateLimitErrororProviderErrorlike their parent status.UserAuthError(401/403) orUserError(404) where it used to pass.wrap_errorreturns a typed error for SDK subclasses it used to return raw. Both are in the CHANGELOG.Risk / rollback
The main risk is a live Files API answering in a way the mocked SDK doesn't: a missing
USE CATALOGgrant returning something outside 401/403/404 would warn and pass, which is the safe direction but leaves that case uncaught. Revert the PR to restore the old check.How it was verified
The new precheck and classification tests were red on the pre-fix code and green after, and the full unit suite passes locally on the rebased branch. Nothing ran against a real Databricks workspace; see the waiver below.
Proof
Repro (local).
DatabricksVolumesUploader.precheckdriven against a fakeddatabricks.sdkWorkspaceClient, withfiles.uploadraising the SDK's 403PermissionDenied, then its 404NotFound, for/Volumes/catalog/schema/volume/path. The precheck returned cleanly every time and never calledfiles.upload: its only live call wascurrent_user.me().Failing tests on the pre-fix code (
uv run --locked --no-sync pytest test/unit/connectors/databricks/test_volumes.py -q):The rows that already passed before the fix, and had to stay green, are the no-false-failure guarantees: credentials rejected at
me(), and a 429/500/503 or unrecognised probe failure passing with a warning.Subclass fix, red on the write-probe commit alone:
After: every test in
test/unit/connectors/databricks/test_volumes.pypasses, anduv run --locked --no-sync pytest -n auto test/unit --ignore test/unit/unstructured -qpasses.Dependencies / merge order
none
🤖 Generated with Claude Code