fix(storage): address the DSN bucket through the endpoint and refuse a DSN that names no device - #246
Merged
Merged
Conversation
…a DSN that names no device A connection string that failed to parse, or whose scheme named no device, was warned about and then resolved to a Local device rooted at /. Every caller then wrote inside its own container and reported success: the WAL shipper shipped segments to its own ephemeral filesystem and published a heartbeat next to them, backup jobs wrote dumps into their own container, and the health check that reads the archive built the same Local device and answered from the controller. Nothing durable, and nothing red. An empty connection keeps its own meaning, "no object storage is configured for this deployment", because several callers depend on it. The endpoint also dropped the DSN port, so the shape the edge README documents, s3://user:password@host:30000/storage?insecure=true, addressed port 80. The bucket was folded into the device root instead (#242), which reaches a request only through getPath(). write(), read() and exists() build their URI from the object path alone, so a caller that passes a whole object key, as every backup and WAL caller does, addressed the store with no bucket anywhere and landed wherever the first segment of that key happened to point. utopia-php/storage 4.0.2 parses the endpoint URL and signs the path it carries, so the bucket belongs there: it reaches every request whichever method builds the URI, and a caller that goes through getPath() resolves to the same object it did before. Refs DAT-2205, DAT-2206 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR corrects storage DSN resolution so invalid schemes are rejected and S3-compatible endpoints retain their port and bucket path.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (3): Last reviewed commit: "fix(storage): keep file:// local, apply ..." | Re-trigger Greptile |
The migration path for a deployment that does not want its objects to move is to take the bucket out of the DSN path, so that has to stay true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, and drop the "0" bucket quirk Three inputs the first pass got wrong, all of them the same mistake: a bucket that reaches no request, or a device that quietly becomes Local. README documents file://localhost for local storage in three places and an edge unit test uses file:///tmp, so refusing an unknown scheme turned a documented working value into a hard failure. Local storage has no host, port or bucket to read, so the scheme alone now decides it, before the DSN is parsed at all. That also makes file:///tmp work, which previously only survived because the parse failure was swallowed. The url parameter replaces the scheme, host and port, but the bucket still has to reach the request: url=http://minio:9000 with /bucket in the DSN path is the spelling README documents, and taking the bucket only from the url would have dropped it. Whether addressing is virtual-hosted or path-style is now decided from the endpoint rather than the scheme, so the bucket lands exactly once however it is spelled and the two documented forms resolve to one URL. A bucket literally named "0" was treated as no bucket, a falsy-string quirk with no reason behind it. The Rust shipper does not reproduce it and the two sides have to agree on every input, so it is gone. Refs DAT-2205, DAT-2206 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.
Fixes two defects in
OpenRuntimes\Executor\StorageFactoryfound while porting the WAL shipper to Rust: DAT-2206 and DAT-2205.DAT-2206: a DSN that names no device silently became a Local device
getDevice()caught every parse failure, logged a warning and fell through todefault: return new Local($root). So an unparseable DSN, or any scheme outsides3/dospaces/backblaze/linode/wasabi(includingawss3, whichDeviceTypedefines), produced a Local device rooted at/. Every caller then wrote inside its own container and reported success: the WAL shipper "shipped" segments to its own ephemeral filesystem and published a heartbeat next to them, backup Jobs wrote dumps into their own container, and the health check that reads the archive built the same Local device and answered from the controller's filesystem. Full green, nothing durable.getDevice()now throwsInvalidArgumentExceptionfor both cases, and the scheme is resolved through an exhaustivematchoverDeviceType, so a device added upstream cannot silently fall back to Local again.An empty or null connection keeps its meaning: "no object storage is configured for this deployment". It short-circuits to
Localbefore any parsing. Callers that depend on it (_EDGE_CONNECTIONS_BACKUPS_STORAGEunset,OPR_EXECUTOR_CONNECTION_STORAGEunset) are unaffected.DAT-2205: the port was dropped and the bucket never reached a request
getDevice()read$dsn->getHost()and never$dsn->getPort(), so the shape the edge README documents,s3://user:password@host.docker.internal:30000/storage?insecure=true, resolved tohttp://host.docker.internalon port 80.The bucket was folded into the device root (#242). That reaches a request only through
getPath().S3::write(),read(),exists()/getInfo()anddelete()build their URI from the object path alone, so a caller that passes a whole object key, which is what every backup and WAL caller does, addressed the store with no bucket anywhere and landed in whatever bucket the first segment of the key happened to name.utopia-php/storage4.0.2 (utopia-php/storage#107) parses the endpoint URL, keeps its port and path, and signs that path in the SigV4 canonical request. The bucket therefore belongs in the endpoint, where it reaches every request whichever method builds the URI:(insecure ? http : https)://host[:port]{endpoint}/{bucket}/{objectKey}dospaces://): object URL ={endpoint}/{objectKey}, bucket not repeated?url=keeps its current meaning and winsThe root is left alone, so a caller that goes through
getPath()resolves to exactly the object it did before: the bucket moved from the front of the root to the end of the endpoint, and the concatenation is unchanged.This raises the
utopia-php/storagefloor to^4.0.2. On 4.0.0 an endpoint path is neither signed nor stripped from theHostheader, so the fix silently producesSignatureDoesNotMatch.Resolved URLs, before and after
Object key
wal-archive/db-abc/000000010000000000000001in every row. "Before" values were read off the reverted implementation, not predicted.s3://key:secret@host.internal:30000/storage?insecure=truehttp://host.internal/wal-archive/db-abc/0000…01http://host.internal:30000/storage/wal-archive/db-abc/0000…01s3://key:secret@s3.amazonaws.com/backups?region=us-east-1https://s3.amazonaws.com/wal-archive/db-abc/0000…01https://s3.amazonaws.com/backups/wal-archive/db-abc/0000…01dospaces://key:secret@fra1.digitaloceanspaces.com/appwrite-backups?region=fra1https://appwrite-backups.fra1.digitaloceanspaces.com/wal-archive/db-abc/0000…01s3://key:secret@mybucket.s3.us-east-1.amazonaws.com/mybucket?region=us-east-1https://mybucket.s3.us-east-1.amazonaws.com/wal-archive/db-abc/0000…01s3://…?url=http://127.0.0.1:3900/mybuckethttp://127.0.0.1:3900/mybucket/wal-archive/db-abc/0000…01getDevice('/build-cache', 's3://…@minio…/storage?insecure=true')->getPath(…)http://minio…/storage/build-cache/cache-key/lz4-b1M/stores.sqfss3://user:password@minio.edge.svc.cluster.local/storage?insecure=truehttp://minio…/wal-archive/db-abc/0000…01http://minio…/storage/wal-archive/db-abc/0000…01Production (
dospaces://) is byte-identical, as is every path that goes throughgetPath(), which covers the executor's build cache and edge's static-site reader.The last row does change. The local and CI MinIO connection names bucket
storage, and today that bucket is dropped: the object key's leading segment (wal-archive/,backups/) is what MinIO takes as the bucket, which is why the local chart creates all three. After this change those objects live under bucketstorage, at the same keys. Both buckets are created by the chart and the clusters are rebuilt per run, so nothing is stranded, but it means the controller, the backup/restore Jobs and the WAL shipper have to be upgraded together, exactly as DAT-2205 requires. Worth noting that today's split is already incoherent in that deployment:Storage::upload()writes at{endpoint}/{key}while the static-site reader goes throughgetPath()and reads at{endpoint}/storage/{key}. This change converges them.Why not make
S3honour its root insteadDAT-2205 offers that alternative. It is the wrong place.
Device::getPath()is defined asroot . '/' . filenameand callers pass its result straight intowrite(); ifwrite()also applied the root, every existing consumer (server-ce, cloud, edge, this repo's build cache) would double it. Fixing it inutopia-php/storagetherefore means a major version and a coordinated upgrade of four repositories, to reach a place where root and bucket are still conflated. The endpoint already models "the thing every object key hangs off", 4.0.2 signs it correctly, and no change outside this repository is needed.Tests
tests/unit/Executor/StorageFactoryTest.php:testConnectionResolvesToObjectUrlpins the exact object URL for all seven DSNs in the table above, by driving the real device through a recording PSR-18 client (the seamS3already takes) and asserting the URI of the request it hands over. The Rust shipper carries the mirror of this table.testPathStyleConnectionPutsBucketAndPortOnTheWirebinds a local listener, has a child process write through a real cURL client, and asserts the request line andHostheader off the socket. It proves the recording client is not flattering the code, and that the SigV4 signature covers the bucket path.testRootedDeviceAddressesTheSameObjectThroughGetPathpins the build-cache round trip that must not move.testUnusableConnectionIsRefusedcovers no host, no scheme, unparseable, unknown scheme, misspelled scheme.testUnconfiguredConnectionResolvesToLocalDevicepins the deliberate empty-DSN meaning.testDeviceTypeFollowsTheSchemewalks everyDeviceType, and catchesawss3resolving to Local.Seen red: reverted
StorageFactoryto itsmainimplementation, keeping only the client parameter so the doubled client still reaches the device, and confirmed 11 of the 17 failed with the "Before" URLs above. The 6 that stayed green are the byte-identical rows, which is the intended result.composer test:unit(29 tests, 94 assertions),composer format:check,composer analyze,composer refactor:checkall pass.What a deployment has to do about it
Any deployed
s3://DSN whose path names a bucket starts addressing a different object, because that is the defect. Two consumers of this factory are in that position, and both are the same in-cluster MinIO connection:_EDGE_CONNECTIONS_BACKUPS_STORAGE=s3://…@minio…/storage?insecure=true, keyswal-archive/{id}/…andbackups/{id}/…, so the bucket iswal-archive/backupstoday and becomesstoragewith those as key prefixes._EDGE_CONNECTIONS_STORAGE/_APP_CONNECTIONS_STORAGE=fra=s3://…@minio…/storage?insecure=true, keys/storage/functions/…and/storage/builds/…, so the bucket isstoragetoday and staysstoragewithstorage/added to the key.A deployment that wants today's addressing back does not need the objects moved. Drop the bucket from the DSN path.
s3://user:password@minio.edge.svc.cluster.local?insecure=trueresolves to a bare endpoint, the object key's leading segment keeps naming the bucket exactly as it does now, and every existing object stays where it is. That is covered by thepath-style without a portandvirtual-hostedrows above: a DSN with no bucket in its path is untouched by this change.So the migration is per deployment and opt in: keep the bucket in the DSN and the bucket reaches the request (and the objects move once), or take it out and nothing changes.
dospaces://needs neither.🤖 Generated with Claude Code