Repository navigation
Add OCI Object Storage: buckets, objects, multipart, PARs - #422
Conversation
NitinKumar004
left a comment
There was a problem hiding this comment.
Review notes
Real data-plane engine (per #427): N/A — OCI Object Storage is object storage, which is explicitly NOT engine-eligible under the #427 model (ComputeEngine/DatabaseEngine/CacheEngine/FunctionEngine/ContainerEngine).
Findings
High · wire-fidelity — GetNamespaceMetadata (GET /n/{ns}) is misrouted to ListBuckets when the tenancy namespace begins with 'b'
server/oci/objectstorage/handler.go:287
If a user's tenancy hashes to a namespace starting with 'b' -> every GetNamespaceMetadata call (GET /n/{ns}) returns 400 'compartmentId is required' instead of the metadata body -> S3/Swift-compat setup and any SDK flow that reads namespace metadata breaks non-deterministically for ~6% of tenancies, while the identical code works for everyone else. Fix: carry a 'hadBucketSegment' bool out of parseNamespaced instead of re-sniffing the raw path.
serveBucketCollection distinguishes /n/{ns} from /n/{ns}/b with strings.Contains(r.URL.Path, "/"+segBuckets) (segBuckets=="b"). The parsed route already discards whether a /b segment was present, so this substring heuristic is the only signal. For a namespace whose first char is 'b', the path "/n/b25a97828eed" contains the substring "/b", so the request falls through to the GET branch = listBuckets, which requires compartmentId. Reproduced with tenancy "ocid1.tenancy.oc1..probe0100" -> namespace
Medium · docs — docs/services.md not updated with OCI Object Storage (Definition-of-done item)
docs/services.md:59
If a reader consults docs/services.md to learn which OCI services cloudemu emulates -> Object Storage is invisible there even though it is fully implemented -> the service is undiscoverable via the canonical human-facing doc, and the PR fails a stated done-criterion (a future migration-drift CI check keys off this file).
docs/oci-conventions.md Definition of done requires 'Operations added to docs/services.md'. The storage section header (line 59) still reads 'AWS: S3 | Azure: Blob Storage | GCP: GCS' with no 'OCI: ObjectStorage' entry, unlike networking (line 407 'OCI: VCN ...'), OCI Monitoring (623) and Identity (670) which each have OCI subsections. Only the generated docs/coverage/* files were updated in the diff; the hand-maintained services.md was not touched.
Medium · coverage — Changed packages below the 90% pillar; lifecycle-expiry and versioning wrappers untested
providers/oci/objectstorage/retention.go:366
If EvaluateLifecycle mis-evaluates expiry (e.g. off-by-one on the ExpirationDays*24h window, or a prefix mismatch) -> a caller relying on lifecycle-expiry reporting gets wrong object lists -> the regression ships silently because no test exercises the aged-out branch. Add a FakeClock test that advances past ExpirationDays and asserts the expired name, plus a HeadObjectVersion and portable-versioning round-trip.
go test -cover: providers/oci/objectstorage 66.2%, server/oci/objectstorage 71.6% (pillar target 90%). 0%-covered flows include EvaluateLifecycle + objectExpired (retention.go:366/399 — object age-out evaluation is never asserted by any test), HeadObjectVersion (versioning.go:228), and the portable SetBucketVersioning/GetBucketVersioning wrappers (versioning.go:140/159). TestBucketLifecycleCRUD stores and reads a policy but never drives an object past its ExpirationDays to confirm EvaluateLifecy
Low · wire-fidelity — ListObjects default page size is 100, not OCI's 1000
server/oci/objectstorage/object.go:233
If a client lists a bucket of 500 objects without an explicit limit -> it receives 100 + a nextStartWith cursor and must paginate 5x -> extra round-trips and a subtle behavioral difference from real OCI. Minor and partly a shared-ocirest.DefaultLimit choice; note only.
listOptions sets MaxKeys: ocirest.Limit(r), which returns ocirest.DefaultLimit (100) when 'limit' is absent and is always >=1, so the provider's defaultListLimit (1000, matching real OCI) at object.go:15 is never reached from the wire path. Real OCI ListObjects returns up to 1000 per page by default.
Low · coverage — No oci-go-sdk SDK-compat test
server/oci/objectstorage/handler_test.go:1
If the handler's wire shape drifts from what the real SDK emits/expects (header casing, envelope fields) -> hand-rolled tests that mirror the handler's own assumptions won't catch it -> a real SDK user hits the mismatch first. Recommended, not blocking.
Convention: 'An SDK-compat test using github.com/oracle/oci-go-sdk against httptest.NewServer is the strongest evidence the handler is right. Add one where the SDK makes it practical.' All wire tests are hand-rolled httptest requests; no oracle/oci-go-sdk client is driven against the handler.
b85c29f to
f285446
Compare
|
Rebased onto current High —
|
NitinKumar004
left a comment
There was a problem hiding this comment.
Review — OCI Object Storage
Verdict: Approve (no blockers). This is the largest and highest-risk surface here (object bytes + multipart + PARs), and it's exemplary — it reuses the portable storagedriver.Bucket interface exactly as S3/GCS/Azure do, stores object bytes in-memory the way S3 does (no external engine required; optional engine offload), and follows the existing ocirest + server/oci/* conventions precisely.
Non-blocking
- Enabling versioning on a bucket that already has objects drops the pre-existing object —
providers/oci/objectstorage/versioning.go:18-41(setVersioningLocked/storeObjectLocked). Reproduced: create unversioned bucket → PUT object → enable versioning → PUT overwrite →objectversionslists only the new version; the original is neither anullversion nor recoverable. Real OCI retains the pre-existing object as thenullversion and preserves it on the next overwrite. The common IaC path (versioning = "Enabled"declared at bucket create) works perfectly — this only bites a create-then-enable sequence. - Object ETag is content-addressed (sha256 of bytes) —
providers/oci/objectstorage/objectstorage.go:327(objectETag). Real OCI mints a fresh opaque ETag per PUT; here an identical re-PUT yields the same ETag, and two distinct objects with identical content share one. A client using If-Match/If-None-Match on ETag could see a stale-looking match after an identical re-upload. - Generic OCI error codes from the shared codec —
server/wire/ocirest/ocirest.go:75-98(statusFor). Bucket-not-empty →409 IncorrectStateand duplicate create →409 Conflict, where real OCI usesBucketNotEmpty/BucketAlreadyExists. Pre-existing shared behavior (not introduced here); noting because it now affects object-storage fidelity for a client branching on the exact code string. CompleteMultipartUploadassembles the whole object via repeated append under the global write lock —providers/oci/objectstorage/multipart.go:155-164. A multi-GB commit does a large memcpy while holdingm.mu, briefly blocking unrelated buckets. Bounded by uploaded data and consistent with the cohesive-mock single-mutex design; acceptable.
Verified good
Full lifecycle over the wire: get-namespace → create bucket → PUT object bytes → GET/HEAD bytes match with stable ETag/opc-content-md5 across GET/HEAD/List → multipart create/upload-parts/commit assembles in ascending part order → abort invalidates the upload → PAR create + redeem returns bytes, read-only PAR write → 403, GET PAR omits accessUri → versioning enable + overwrite lists versions with delete markers (newest-first, isLatest) → delete → 204 → GET-after-delete → 404 → delete non-empty bucket → 409. Computed IDs/namespace/etag/timeCreated stable across reads; Snapshottable present and auto-discovered (persistence round-trips objects + versions + PAR tokens); object size / part count / list page size all bounded; single-mutex spans check-then-set on every create (no TOCTOU race); reads deep-copy; -race clean; structure/lint/go generate and the full CI matrix all green.
NitinKumar004
left a comment
There was a problem hiding this comment.
This PR no longer merges with development, and against a running serve I hit a routing collision, PAR URLs that break after a restore, and lifecycle and conditional-request gaps that a real client would notice.
Earlier review points
- GET /n/{ns} misrouted for a namespace starting with b: fixed.
HasBucketSegis in place andTestNamespaceMetadataWithBPrefixedNamespacepasses. - docs/services.md: fixed.
- Coverage: fixed. It is now 96.0% (provider) and 95.5% (wire).
- ListObjects default page size: fixed. It is 1000 when no limit is given.
- oci-go-sdk compat test: not added. The author explained why. That is fine with me, but see the Terraform note under Checked.
- Enabling versioning after objects exist drops the original: still open. Details are under Medium.
- Content-addressed object ETag: still open.
objectETagis still sha256 of the bytes. - Generic error codes (
Conflict/IncorrectStateinstead ofBucketAlreadyExists/BucketNotEmpty): still open. - Multipart commit runs under the global lock: unchanged, and acceptable as noted before.
High
1. Does not merge with current development
- Conflicts are in
contrib/realengine/blobstore/blobstore.go, where development reworded the package doc, and indocs/coverage/README.md, which is generated. - After resolving them,
go run ./internal/coveragegenstill changesdocs/coverage/README.md, because the storage row has no OCI link. It also changesdocs/coverage/oci/objectstorage.md. - Fix: rebase on development, rerun
go generate ./...and commit the output. - On the merged tree,
go build ./...andgo vetare clean. Tests pass for providers/oci, server/oci, persist (TestSnapshotCompleteness), internal/coveragegen, serveflags, serverkit and seed.-racepasses on providers/oci and server/oci.
2. The work request handler takes over Object Storage paths (server/oci/oci.go:86, server/oci/workrequest/workrequest.go:200)
- The workrequest handler is registered first. It claims any GET whose path has a
workRequestssegment followed by at most two more segments. Object keys and bucket names are user data, so real objects become unreachable. - Repro against serve:
PUT /n/{ns}/b/tfb/o/workRequests/xreturns 200.GETon the same path returns404 {"code":"NotAuthorizedOrNotFound","message":"work request x not found"}.- Create a bucket named
workRequests, thenGET /n/{ns}/b/workRequestsreturns400 compartmentId is required.
- Fix: have the workrequest handler ignore
/n/...and/p/...paths. Or register Object Storage before it, since itsMatchesis anchored at/nand/p/{token}/n. Add a test with a key and a bucket namedworkRequests.
Medium
3. PAR tokens collide after a persist restart (providers/oci/objectstorage/par.go:123)
- The redemption token comes from
idgen.GenerateID(""), which is the process counter. On restart that counter starts again from 1 while restored PARs keep their tokens. - Repro:
- Start
serve --providers oci --persist --persist-strategy on-shutdown --state-file s.json. - Create a PAR on bucket
secret. Its access URI is/p/00000007/.... - Restart, then create a PAR on bucket
public. It also gets/p/00000007/...and the same PAR OCID. - Now
GET /p/00000007/n/{ns}/b/secret/o/payroll.csvreturns 403 on every attempt.
- Start
- This breaks the claim that an access URI issued before a snapshot still redeems after restore.
- Fix: mint the token from crypto/rand.
idgenalready has random helpers. Also check for collisions inResolvePAR, or key PARs by token.
4. Enabling versioning after objects exist loses the original object (providers/oci/objectstorage/versioning.go:18)
- Repro: PUT
k= "original", then UpdateBucket{"versioning":"Enabled"}, then PUTk= "second".GET /objectversionslists one version, and "original" is gone. setVersioningLockedonly allocates the history map. It never seeds the existing objects as versions.- Fix: when versioning moves to Enabled, add each current object to its chain before the first overwrite.
5. Lifecycle rules do not round-trip (server/oci/objectstorage/lifecycle.go:141, :129, types.go:221)
- PUT
{"timeAmount":1,"timeUnit":"YEARS"}reads back astimeAmount:365,timeUnit:"DAYS". Any client that compares the policy it set with what it reads back will see a diff. targetandobjectNameFilter.inclusionPatterns/exclusionPatternsare silently dropped. Repro: PUT a DELETE rule withtarget:"previous-object-versions"andinclusionPatterns:["*.log"]. It is accepted with 200, and GET returns a plain DELETE rule with no target and no filter, so the rule would now act on live objects.- The PR says nothing is accepted and then ignored, but that is not the case here.
- Fix: store the unit, target and all three filter lists as sent. Reject
ABORTunless the target ismultipart-uploads. Return the policy'stimeCreated.
6. Conditional and range headers are ignored (server/oci/objectstorage/object.go:56, :94)
PUTwithif-none-match: *on an existing object returns 200 and overwrites it.PUTwithif-match: bogusalso returns 200 and overwrites. Real OCI answers 412IfNoneMatchFailed/IfMatchFailed.GETwithRange: bytes=0-2returns 200 with the full body. Real OCI returns 206 withContent-Range. Ranged and parallel downloads (CLI, rclone, SDK range reads) get the wrong bytes.- Fix: honour
if-matchandif-none-matchon Put, Get, Head and Delete object and on UpdateBucket/DeleteBucket. Also support a single byte range on GetObject.
7. UpdateBucket silently ignores a rename (server/oci/objectstorage/types.go:28)
UpdateBucketDetails.namerenames the bucket in OCI. HerePOST /n/{ns}/b/tfb {"name":"tfb2"}returns 200 withname:"tfb", andGET /b/tfb2returns 404.- Fix: support the rename and move the bucket's stores under the new key. If that is out of scope, reject a
namethat differs from the current one with 400.
8. Compartment existence is not checked (server/oci/objectstorage/bucket.go:36, providers/oci/objectstorage/bucket.go:112, :195)
CreateBucketwithcompartmentId: "ocid1.compartment.oc1..doesnotexist"returns 200.- Development now rejects that for VCN through
SetCompartmentChecker(#1295). Real OCI answers 404NotAuthorizedOrNotFound. - Fix: wire the same identity-backed checker in
server/oci/oci.gofor create and for thecompartmentIdmove on update.
9. PAR rules are stricter than OCI (providers/oci/objectstorage/par.go:16, :144, :96)
- A
timeExpiresthree months out is rejected withexceeds the maximum lifetime of 168h0m0s. Seven days is the S3 presign limit. OCI PARs commonly live for months or years, for example a Terraformtime_expiresa year out. AnyObjectReadwithobjectName:"logs/"is rejected. OCI treatsobjectNameon the AnyObject types as a prefix.- Fix: drop the cap or raise it far enough not to reject realistic values, and support prefix-scoped bucket PARs in
PARAllows.
Low
- Error codes (
server/wire/ocirest/ocirest.go:75): a duplicate bucket returns409 Conflictand a non-empty bucket returns409 IncorrectState. OCI usesBucketAlreadyExistsandBucketNotEmpty. Map these in the Object Storage handler rather than in the shared codec. - ETag is a content hash (
providers/oci/objectstorage/objectstorage.go:327): two identical PUTs return the same ETag. Once item 6 lands,if-matchwill treat a re-upload as unchanged. Mint an opaque ETag per write. - Pagination is missing: ListBuckets, ListObjectVersions, ListPreauthenticatedRequests and ListMultipartUploads ignore
limitandpageand never setopc-next-page.objectversions?limit=1returned all 3 versions. - copyObject drops fields (
server/oci/objectstorage/object.go:323):destinationRegionis required by OCI but not checked.eu-frankfurt-1was copied locally with 202.destinationObjectMetadata,destinationObjectStorageTierand the if-match fields are dropped. Reject a foreign region and apply or reject the other fields. - Multipart commit does not check ETags (
server/oci/objectstorage/multipart.go:131):partsToCommitwith"etag":"wrong-etag"commits successfully. Compare each part's ETag. - Bucket names are not validated:
"bad name!"is accepted. OCI allows letters, digits,-,_and., up to 256 characters.
Checked
- Real-user flow on
serve --providers ociover raw HTTP:- Bucket create, then GET twice (byte-identical), update, list and delete.
- Object put, get and head (Content-Length and type correct).
- Version list and delete markers.
- Async copy returns 202, and
/workRequests/{id}reports SUCCEEDED. - Multipart create, upload and commit.
- PAR redeem works, write through a read PAR returns 403, and redeem after revoke returns 404.
- Wrong namespace returns 404, and deleting a missing object returns 404.
- Terraform with oracle/oci was not run. The provider has no per-service endpoint override I know of, so it cannot reach a local port without DNS and TLS interception.
- Wiring: provider factory,
DriversFrom, monitoring wiring, resource discovery and the seed target all pick up Object Storage. - Snapshot:
Snapshottableis discovered by the persist completeness test. - Architecture: behaviour lives in the provider and the wire layer delegates, so the library and serve behave the same.
- Lint and format: golangci-lint is clean on the new packages, and gofmt is clean.
--enforce-auth: there is no OCI authorization gate on development, so there is nothing to declare for this handler.
|
Please merge the latest |
f285446 to
329035e
Compare
|
Rebased onto High1. Rebase. 2. Work requests taking over Object Storage paths. Fixed at the root in One deviation from a pure version-anchored rule: I kept the unversioned
Medium3. PAR tokens after a persist restart. Tokens are now 48 bytes from Worth flagging beyond this PR: every OCI service mints OCIDs from that process counter, and persist never advances it. That is the underlying cause, and it can affect any OCI service across a restore. I've fixed the PAR case here, but the general fix belongs in a separate change. 4. Versioning after objects exist. Turning versioning on now records every existing object as a version, moving its engine bytes to the versioned key. Covered for all three ways of enabling it, with and without an engine. 5. Lifecycle round-trip. The provider stores the OCI policy itself rather than the portable shape: unit, target, all three filter lists and 6. Conditional and Range headers. Preconditions are honoured everywhere you listed, plus both sides of One deviation: a matching 7. UpdateBucket rename. Implemented: objects, versions, PARs and engine bytes move to the new name. A taken name returns 8. Compartment check. Mirrors development's VCN pattern exactly, wired from 9. PAR rules. The week cap is removed, so a PAR a year out is accepted. Lows (all done)
Aligning with #421 (
|
NitinKumar004
left a comment
There was a problem hiding this comment.
Every finding from the last round is fixed, and I confirmed each one against serve with the real oci-go-sdk (v65.126.1). Two new problems turned up in the same areas. Restored versions and retention rules can still be overwritten after a restart, and copy work requests report a status the Object Storage SDK does not define.
Earlier findings
- High 1, merge with development: fixed. The branch is based on the current tip of development (770204c), so it merges cleanly.
go run ./internal/coveragegenleaves no diff indocs/coverage. - High 2, work requests taking over Object Storage paths: fixed. PUT and GET of
tfb/o/workRequests/xreturnpayload. A bucket namedworkRequestscan be created, read, written to and listed. Copy work requests still answer on/workRequests/{id}throughGetWorkRequest, andListWorkRequestsworks too. - Medium 3, PAR tokens after a persist restart: fixed. I ran the original repro: start with
--persist --persist-strategy on-shutdown, create a PAR onsecret, send SIGINT, restart, then create a PAR onpublic. The two PARs got different OCIDs and different tokens, and thesecretURI still returnssalaries. - Medium 4, enabling versioning after objects exist: fixed. The sequence PUT
original, enable versioning, PUTsecondnow lists 2 versions, and both bodies come back by versionId. Suspending, writing, re-enabling and writing again keeps all 5 versions. - Medium 5, lifecycle round-trip: fixed. A rule set as
1 YEARSwith targetprevious-object-versionsand all three filter lists reads back exactly as sent, andtimeCreatedis set. ABORT on theobjectstarget returns 400. - Medium 6, conditional and range headers: fixed. PUT with
if-none-match: *returns 412IfNoneMatchFailed. A bogusif-matchreturns 412IfMatchFailedon PUT, GET, DELETE and UpdateBucket. A matchingif-none-matchon GET and HEAD returns 304. Rangesbytes=0-2,bytes=-3andbytes=6-return 206 with the right bytes andContent-Range.bytes=50-60returns 416. - Medium 7, UpdateBucket rename: fixed.
rbrenamed torb2keeps its objects, the old name returns 404, and renaming onto a taken name returns 409BucketAlreadyExists. - Medium 8, compartment check: fixed. CreateBucket and a compartment move both return 404
NotAuthorizedOrNotFoundfor a compartment that does not exist. - Medium 9, PAR rules: fixed. A PAR that expires a year out is accepted. With
objectName: "logs/"on AnyObjectRead,logs/a.txtredeems andother/b.txtreturns 403. A missingtimeExpiresreturns 400. - Low, error codes: fixed. Duplicate bucket returns
409 BucketAlreadyExists. A non-empty delete returns409 BucketNotEmpty. - Low, ETag: fixed. Two identical PUTs now return different ETags.
- Low, pagination: fixed.
objectversions?limit=1returns 1 item plusopc-next-page, and page 2 returns a different version. Paging ListBuckets withlimit=2returns the same total as one unpaged call. - Low, copyObject: fixed.
destinationRegion: eu-frankfurt-1returns 400. - Low, multipart ETags: fixed.
wrong-etagis rejected with 400, and the real part ETag commits. - Low, bucket names: fixed.
bad name!returns 400.
New findings
Medium 1. Version ids and retention rule OCIDs still collide after a persist restore (providers/oci/objectstorage/versioning.go:15, providers/oci/objectstorage/retention.go:82)
This is the same root cause as the PAR token issue. The PAR fix did not cover the other ids this service mints. newVersionID and the retention rule OCID both come from the process counter, which starts again from 1 on restart.
Repro for versions:
- Start
serve --providers oci --persist --persist-strategy on-shutdown. - Create bucket
vbwith versioning Enabled and PUTksix times. - Send SIGINT and restart, then PUT
ktwelve more times. GET /objectversionsreturns 18 items but only 13 distinct ids.0000000f,0000000d,0000000b,00000009and00000007each appear twice.GET /o/k?versionId=0000000freturnsbefore-6. The newer version with the same id cannot be read, and a delete by that versionId is ambiguous. With a storage engine wired,engineRef(bucket, key, version)is the same for both versions, so the new write replaces the old version's bytes.
Repro for retention rules:
- Before the restart, create three 365-day rules on
rb. They get the OCIDs...05,...07and...09. - After the restart, create six 1-day rules.
- The list now shows six rules, all 1-day. The new rules reused
...05,...07and...09, andbkt.retention.Set(rule.ID, rule)silently replaced the original 365-day rules. A rule withtimeRuleLockedset can be replaced the same way.
Fix: mint version ids with idgen.UUID(), since real OCI version ids are opaque UUIDs. Give retention rule OCIDs a random suffix as well, or refuse a Set that would replace an existing id. Bucket OCIDs collide the same way: after the restart a new bucket got ...12, the same OCID as vb. You already called that out as the general OCI counter problem, so a tracked follow-up is fine for buckets. Versions and retention rules lose data here, so they should be fixed in this PR. Add a restore test for both, like TestPARsDoNotCollideAfterRestore.
Medium 2. Copy work requests report SUCCEEDED, which is not an Object Storage status (server/oci/objectstorage/object.go:482, server/oci/workrequest/workrequest.go:93)
The Object Storage WorkRequestStatusEnum is ACCEPTED, IN_PROGRESS, FAILED, COMPLETED, CANCELING and CANCELED. The shared store always writes SUCCEEDED, which is the right value for VCN and Identity but not for Object Storage.
Repro: run CopyObject, then GetWorkRequest on the returned opc-work-request-id. The status is SUCCEEDED, and objectstorage.GetMappingWorkRequestStatusEnum("SUCCEEDED") returns false. A copy waiter that polls for COMPLETED never finishes. That includes the Terraform object copy and oci os object copy --wait-for-state COMPLETED.
Fix: let Accept take the terminal status, or add an Object Storage variant, and record COMPLETED for COPY_OBJECT. Add an SDK-shaped test that checks the status against the Object Storage enum.
Checked
- The branch merges cleanly with origin/development, and coveragegen leaves no diff.
go build ./...is clean.go vetis clean on providers/oci, server/oci, services/storage, serveflags, persist and internal/coveragegen.- Tests pass on those packages and on contrib/realengine/blobstore.
-racepasses on providers/oci and server/oci. golangci-lint --new-from-rev=origin/developmentreports 0 issues.- The
metrics.gohunks match #421 line for line, so whichever PR merges second will not conflict there. - Every earlier repro was run against
servewith oci-go-sdk v65, including a real SIGINT restart for the persist cases. - Terraform was not run, for the same reason as before. The oracle/oci provider has no per-service endpoint override.
|
Thanks. Both Mediums are fixed in the latest commit, rebased on current 1. Version ids and retention rule OCIDs after restore
2. Copy work request status
Local: |
329035e to
8e757e8
Compare
8e757e8 to
bf70760
Compare
NitinKumar004
left a comment
There was a problem hiding this comment.
Both open items from the last round are fixed, and I confirmed them with a real --persist restart. This time I drove the whole service with the real oci-go-sdk (v65.126.1) and found one new break. UpdateRetentionRule is served on the wrong HTTP method, so the SDK, the CLI and a Terraform retention rule change all get 405. There are also a few smaller fidelity gaps around PAR bucket listing and MD5 headers.
Earlier findings
| Finding | Status | Evidence |
|---|---|---|
| Medium 1: version ids and retention rule OCIDs collide after a persist restore | Fixed | serve --persist --persist-strategy on-shutdown, 6 PUTs to a versioned key and three 365-day rules, SIGINT, restart, then 12 more PUTs and six 1-day rules. objectversions returns 18 items with 18 distinct UUID ids. The rules list has 9 entries, and all three 365-day rules are still there. A new bucket created after the restart got a fresh OCID (...3a), not one held by vb (...04) or rb (...0c), so the counter restore from development covers buckets too. Reading the oldest version by id after the restart returns before-1. |
Medium 2: copy work requests report SUCCEEDED |
Fixed | CopyObject followed by GetWorkRequest through the SDK returns COMPLETED, and objectstorage.GetMappingWorkRequestStatusEnum accepts it. Both /workRequests/{id} and /20160918/workRequests/{id} return COMPLETED. VCN and Identity still go through Accept and keep SUCCEEDED. |
| All earlier rounds (routing, PAR tokens, versioning seed, lifecycle round-trip, conditionals and ranges, rename, compartment check, PAR rules, error codes, ETags, pagination, copy validation, multipart ETags, bucket names) | Still fixed | Re-checked through the SDK: if-match 412 on UpdateBucket, Range: bytes=1-3 returns ell with Content-Range: bytes 1-3/5, the lifecycle policy round-trips, a PAR a year out is accepted, and a wrong part number returns 400. |
High
1. UpdateRetentionRule is served on POST, but the API (and the SDK, CLI and Terraform) use PUT (server/oci/objectstorage/retention.go:29)
PUT /n/{ns}/b/{bucket}/retentionRules/{id}returns405 MethodNotAllowed. Repro with oci-go-sdk v65:client.UpdateRetentionRule(...)fails withHttp Status Code: 405 ... PUT is not allowed on /n/.../retentionRules/ocid1.retentionrule....- The SDK sends PUT for this operation (
objectstorage_client.go,http.MethodPut, "/n/{namespaceName}/b/{bucketName}/retentionRules/{retentionRuleId}"), and the PR's owndocs/services.mdtable also saysPUT. - Who it breaks:
oci os retention-rule updateand Terraform. Changing aretention_rules { duration {...} }block onoci_objectstorage_bucketgoes throughupdateRetentionRulesHelper→UpdateRetentionRule, so the apply fails. Locking a rule later by settingtime_rule_lockedfails the same way. - The handler tests drive the update with POST, so they pass against the wrong method.
- Fix: route
http.MethodPuttoupdateRetentionRule. Either drop POST or return 405 for it, and switch the tests to PUT.
Medium
2. A PAR created with bucketListingAction: ListObjects cannot list (server/oci/objectstorage/par.go:119)
- Create an
AnyObjectReadPAR withbucketListingAction: "ListObjects". The create succeeds and echoes the value back. AGETon the returnedaccessUri(/p/{token}/n/{ns}/b/{bucket}/o/) then returns400 a pre-authenticated request addresses an object under /o/. - OCI documents exactly this request: a GET on the PAR URL ending in
/o/returns{"objects":[...]}and accepts the ListObjects query parameters (prefix, fields, start, limit) (Working with PARs). It is the usual way to share a read-only bucket view, and the field is accepted here and then ignored. - Related:
bucketListingActionis not validated."Bogus"is stored and returned. The SDK enum isDeny/ListObjects. - Fix: when
Rest == ""and the PAR is an AnyObject read type withListObjects, servelistObjectsscoped to the PAR's bucket andobjectNameprefix. Otherwise return 403/404 as now. Reject unknownbucketListingActionvalues with 400.
3. MD5 headers do not match the API (server/oci/objectstorage/object.go:557, :57)
- GetObject and HeadObject return the hash as
Opc-Content-Md5. The API returns it asContent-MD5, which is the header the SDK maps toGetObjectResponse.ContentMd5/HeadObjectResponse.ContentMd5. Through the SDK,ContentMd5is nil for every object. Tools that compare hashes from HeadObject (rclone's OCI backend, sync scripts) then cannot compare.opc-content-md5is correct on the PutObject response only. - A multipart object should carry
opc-multipart-md5on CommitMultipartUpload, GET and HEAD. Here commit returns neither header, andHeadObjecton the committed object hasContentMd5andOpcMultipartMd5both nil. - PutObject ignores a
Content-MD5request header.PUTwithContent-MD5: AAAAAAAAAAAAAAAAAAAAAA==and bodyhelloreturns 200 and stores the object. OCI rejects a body whose MD5 does not match with 400 (PutObject), and that header is what the SDK upload manager and Terraform'scontent_md5send to catch corrupt uploads. - Fix: in
stampObjectHeadersemitContent-MD5for single-part objects andopc-multipart-md5(<base64 md5 of the part md5s>-<count>) for multipart ones, keepingopc-content-md5on the PutObject response. VerifyContent-MD5inputObjectanduploadPart.
Low
- Duplicate part numbers in a commit are concatenated twice (
providers/oci/objectstorage/multipart.go:195).partsToCommit[{1},{1},{2}]with partsAAA/BBcommitsAAAAAABB. Reject a repeatedpartNumwith 400 rather than building a corrupt object. - PAR redemption ignores the namespace (
server/oci/objectstorage/par.go:126)./p/{token}/n/wrongns/b/pb/o/logs/a.txtreturns the object. Checkrt.NamespaceagainstNamespace()and return the same 404 as elsewhere. - PAR
accessUriandfullPath(providers/oci/objectstorage/par.go:205,server/oci/objectstorage/par.go:70). An object name with a space comes back unescaped (/o/my file.txt), so the URI cannot be used as is.fullPathis the same relative path asaccessUri, while OCI returns the absolute URL. Path-escape the object name, and buildfullPathfrom the request host. - GetRetentionRule has no
etagresponse header (server/oci/objectstorage/retention.go:77). The body hasetag, butGetRetentionRuleResponse.Etag(a header field in the SDK) is nil. CreateRetentionRule sets the header, so stamp it on Get and Update as well. Last-Modifiedis RFC 3339 (server/oci/objectstorage/object.go:553). HTTP and OCI use the HTTP-date format (Mon, 12 Oct 2026 12:40:52 GMT). The Go SDK tolerates it.http.ParseTimeand most non-SDK clients do not.- Unclaimed SDK operations answer a generic 404:
replicationPolicies,replicationSources,actions/batchDeleteObjects,actions/makeBucketWritableandactions/reencrypt/{object}return404 unknown collection/action.PUT /n/{ns}(UpdateNamespaceMetadata) returns 405./n/{ns}/pe(private endpoints) falls through to501 no handler. Since reencrypt and restoreObjects already get an explicit 501, giving these the same 501 (and listing them under "Not emulated" indocs/services.md) would tell a caller what is going on. The OCI S3 Compatibility API is not emulated either. That is fine, because it lives on its own*.compat.objectstoragehost and the OCI port shares nothing with the AWS S3 handler, but the docs should say so.
OCI docs divergences (summary)
| Operation | Documented | Observed |
|---|---|---|
| UpdateRetentionRule | PUT .../retentionRules/{id} |
405 on PUT, served on POST |
| PAR bucket listing | GET /p/.../o/ lists objects when ListObjects |
400 |
| GetObject / HeadObject | Content-MD5 or opc-multipart-md5 header |
Opc-Content-Md5 only |
| PutObject | Content-MD5 mismatch → 400 |
accepted |
| CreatePreauthenticatedRequest | bucketListingAction ∈ {Deny, ListObjects} |
any string stored |
| CommitMultipartUpload | opc-multipart-md5 header |
absent |
Terraform and serve
- Terraform was not run. The oracle/oci provider has no per-service endpoint override, and the HTTPS proxy route needs a local TLS-intercepting proxy, which I could not set up in this environment. As the end-to-end fallback I drove
serve --providers ociwith the real oci-go-sdk v65.126.1 (NewRawConfigurationProvider,client.Hostset to serve) through each resource the Terraform types map to:- Bucket: create, then get twice (identical body and etag), then update (tags replaced, versioning Enabled, id and timeCreated unchanged). Bogus
if-matchreturns 412. After delete, get returns 404. Deleting a bucket that still holds versions returns 409, and deletes once the versions are purged. - Object: put, then head twice (ETag stable and equal to the put ETag; length and
opc-meta-*correct), a range get, list with fields, and overwrite (2 versions). After delete, get returns 404. - Preauthrequest: create, then redeem with no auth (200, correct bytes), then get twice (identical, timeCreated and timeExpires equal to the create response). After delete, get returns 404.
- Lifecycle policy: put, then get twice (identical, timeCreated equal to put), then update (60 days reads back). After delete, get returns 404.
- Retention rule: create and get twice (identical). Delete of a held object is refused. Delete of the rule followed by get returns 404. Update fails with 405 (High 1).
- Multipart: create, upload 2 parts, list parts, commit. Copy works and its work request reads back as
COMPLETED.
- Bucket: create, then get twice (identical body and etag), then update (tags replaced, versioning Enabled, id and timeCreated unchanged). Bogus
- Mapped to the Terraform flow:
oci_objectstorage_bucket,oci_objectstorage_object,oci_objectstorage_preauthrequestandoci_objectstorage_object_lifecycle_policywould apply and read back with stable computed fields. Aretention_ruleschange on the bucket would fail on update. - Serve: OCI is reachable through
serve --providers oci --oci-port <p>. The Dockerfile and serve wiring are unchanged apart from the new--oci-tenancyflag, so I did not rebuild the image. Library and serve go through the same provider mock, and the wire layer only translates.
What I verified
- Head
bf70760emerges cleanly with development58fdd8c6. The delta since the last review is the one fix commit (UUID version and upload ids, retention rule id guard,AcceptAs/COMPLETED). go build ./...is clean.go vetis clean on providers/oci, server/oci, services/storage, serveflags and persist.go test -racepasses on providers/oci/..., server/oci/..., persist, serveflags, services/storage/... and internal/coveragegen.contrib/realengine/blobstoretests pass.go mod tidyleaves no diff. gofmt is clean.go generate ./...leaves no diff.- golangci-lint 2.14 with
--new-from-revon the touched packages reports 0 issues. The 10goconsthits in monitoring/query.go and serveflags.go are on lines this PR does not touch. - Coverage: providers/oci/objectstorage 96.2%, server/oci/objectstorage 96.2%, server/oci/workrequest 96.4%.
- A concurrent create/update/rename/put/PAR/retention/snapshot/delete probe across 16 goroutines is
-raceclean (3 runs). - Architecture: the provider implements
storagedriver.Bucketplus the handler-sideExtras. The wire layer usesocirest. Copy goes through the shared work request store. The compartment checker matches the VCN pattern.Snapshottableis in place. OCIDs come fromidgenand survive a restore. - CI: every check is green.
Verdict
Request changes. High 1 is a one-line routing fix, but it breaks a documented operation for the SDK, the CLI and Terraform. Mediums 2 and 3 are real fidelity gaps that clients will hit. Everything else looks solid, and the restore and work request fixes hold up under a real restart.
| switch r.Method { | ||
| case http.MethodGet: | ||
| h.getRetentionRule(w, r, rt.Bucket, rt.Rest) | ||
| case http.MethodPost: |
There was a problem hiding this comment.
UpdateRetentionRule is PUT /n/{ns}/b/{bucket}/retentionRules/{id} in the API, and that is what oci-go-sdk sends. The table this PR adds to docs/services.md says PUT too. As written, client.UpdateRetentionRule gets 405 PUT is not allowed, and so do oci os retention-rule update and a Terraform change to a retention_rules block on oci_objectstorage_bucket (it goes through updateRetentionRulesHelper). Route http.MethodPut here and move the handler tests from POST to PUT.
| return | ||
| } | ||
|
|
||
| if rt.Sub != subObjects || rt.Rest == "" { |
There was a problem hiding this comment.
A PAR created with bucketListingAction: "ListObjects" should be able to list: OCI documents GET /p/{token}/n/{ns}/b/{bucket}/o/ returning {"objects":[...]} with the usual ListObjects parameters. Here that request returns 400, so the field is accepted and then ignored. When rt.Rest == "" and the PAR is AnyObjectRead/AnyObjectReadWrite with ListObjects, serve listObjects scoped to the PAR's bucket and objectName prefix. Also validate bucketListingAction against Deny/ListObjects at create ("Bogus" is stored today).
| return | ||
| } | ||
|
|
||
| if rt.Bucket != par.Bucket { |
There was a problem hiding this comment.
The namespace in the redemption path is never checked: /p/{token}/n/wrongns/b/pb/o/logs/a.txt returns the object. Compare rt.Namespace with h.extras.Namespace() and return the same 404 as the rest of the handler.
| w.Header().Set(headerStorageTier, d.StorageTier) | ||
|
|
||
| if d.MD5 != "" { | ||
| w.Header().Set("Opc-Content-Md5", d.MD5) |
There was a problem hiding this comment.
GetObject and HeadObject return the hash as Content-MD5 in OCI (the SDK reads content-md5 into ContentMd5). Only the PutObject response uses opc-content-md5. Through the SDK, HeadObjectResponse.ContentMd5 is nil for every object, so hash-based sync tools cannot compare. Multipart objects should carry opc-multipart-md5 (<md5 of part md5s>-<n>) on commit, GET and HEAD. Today commit returns neither header.
| } | ||
|
|
||
| func (h *Handler) putObject(w http.ResponseWriter, r *http.Request, bucket, object string) { | ||
| data, ok := readBody(w, r) |
There was a problem hiding this comment.
A Content-MD5 request header is never checked: a PUT with Content-MD5: AAAAAAAAAAAAAAAAAAAAAA== and body hello returns 200. OCI rejects a body whose MD5 does not match with 400, and that is the integrity check the SDK upload manager and Terraform's content_md5 rely on. Verify it here and in uploadPart.
|
|
||
| var data []byte | ||
|
|
||
| for _, p := range ordered { |
There was a problem hiding this comment.
A repeated partNum in partsToCommit is appended twice: committing [{1},{1},{2}] over parts AAA/BB stores AAAAAABB. Reject duplicates with InvalidArgument instead of building a corrupt object.
|
|
||
| // accessURI is the path a PAR is redeemed at, matching the shape real OCI | ||
| // returns: /p/{token}/n/{namespace}/b/{bucket}/o/{object}. | ||
| func (m *Mock) accessURI(par *parData) string { |
There was a problem hiding this comment.
The object name goes into the access URI unescaped, so my file.txt yields /o/my file.txt, which a client cannot use as is. Path-escape it. Separately, the handler sets fullPath to this same relative path, while OCI returns the absolute URL.
|
Thanks for the SDK run. Everything is addressed in 78f386f, and every new or changed test fails on the pre-fix code. High
Medium
Low
Local: |
…tention, lifecycle Buckets, objects, multipart uploads, pre-authenticated requests, retention rules, lifecycle policies, versioning and copy work requests, with persistence and Monitoring metrics through the service-side ingest path.
78f386f to
8f7482a
Compare
|
Rebased onto |
NitinKumar004
left a comment
There was a problem hiding this comment.
Every point from the last round is fixed. I checked each one against serve with the real oci-go-sdk (v65.126.1) at 8f7482aa, including a --persist restart. The service code matches what you described, and the rebase onto #421 only touches the shared wiring and the generated coverage docs. Two small gaps are left, both Low and neither blocking.
Earlier findings
| Finding | Status | Evidence |
|---|---|---|
| High: UpdateRetentionRule served on POST | Fixed | client.UpdateRetentionRule (PUT) returns 200 with the new name and duration. The etag header matches the body and changes on update. A raw POST to the rule now returns 405. |
Medium: PAR ListObjects listing returned 400, value not validated |
Fixed | Ran an AnyObjectRead PAR with objectName: logs/ and ListObjects. GET {accessUri} lists only logs/*. prefix=logs/sub narrows the list, prefix=other returns [], limit=1 pages, and delimiter=/ returns prefixes: ["logs/sub/"]. An object outside the prefix gets 403, and so does another bucket. A PAR with no listing action stores Deny, and its listing is still refused. A raw "Bogus" is rejected with 400 (the provider test covers it, and the SDK enum blocks it before the wire). Listing PARs survive a restart, and the old token still lists. |
| Medium: MD5 headers | Fixed (one gap, see Low 1) | GET and HEAD carry Content-MD5, so the SDK's ContentMd5 is now set. PutObject returns opc-content-md5, and UploadPart does too. A mismatched Content-MD5 on PutObject or UploadPart returns 400 InvalidParameter, and the object is not stored. On commit, opc-multipart-md5 is 9ni10fXspkgns40PbziFLQ==-2, which I computed independently for parts AAA/BB. HEAD, GET, ListObjects md5 and a copy of the object all carry the same value, and it is still there after a persist restart. |
| Low: duplicate part numbers | Fixed | [{1},{1},{2}] returns 400 part 1 is listed more than once. |
| Low: PAR namespace not checked | Fixed | A wrong namespace returns 404 NotAuthorizedOrNotFound, both on object redemption and on the listing. |
Low: PAR accessUri / fullPath |
Fixed | my file.txt comes back as /o/my%20file.txt. fullPath is http://127.0.0.1:<port>/p/..., and both URLs redeem to the object. |
| Low: no etag header on GetRetentionRule | Fixed | GetRetentionRuleResponse.Etag is set and equals the body etag. |
Low: Last-Modified format |
Fixed | Mon, 12 Oct 2026 18:05:04 GMT, and http.ParseTime accepts it. Versioned reads use the same format. |
| Low: unclaimed operations return a generic 404 | Fixed | replicationPolicies, replicationSources, batchDeleteObjects, makeBucketWritable, reencrypt/{object}, PUT /n/{ns} and /n/{ns}/pe[/...] all return 501 with a reason. Through the SDK, UpdateNamespaceMetadata, ListReplicationPolicies and MakeBucketWritable surface 501 NotImplemented. Unknown collections and actions still return 404. docs/services.md lists all of them and notes that the S3 Compatibility API is not emulated. |
Earlier rounds (restore collisions, copy work request COMPLETED, routing, conditionals, ranges, lifecycle, PAR tokens) |
Still fixed | Range bytes=1-3 returns ell with Content-Range: bytes 1-3/5. PAR tokens and multipart digests survive the restart. |
High
None.
Medium
None.
Low
1. GET and HEAD by versionId still have no MD5 header (server/oci/objectstorage/object.go:142, :300)
- With
versionIdset, both reads go throughstampInfoHeaders, which sets neitherContent-MD5noropc-multipart-md5. Through the SDK,GetObjectandHeadObjectwithVersionIdreturnContentMd5 = nilfor a plain PUT, while the same object withoutversionIdreports it. - Real OCI returns the version's digest. The version chain already keeps
contentMD5andmultipartMD5, so the data is there. - Fix: expose them through the versioned details and call
stampObjectMD5on those paths too.
2. if-match is ignored on retention rule and lifecycle writes (server/oci/objectstorage/retention.go:60, server/oci/objectstorage/lifecycle.go:24)
UpdateRetentionRulewithif-match: bogusreturns 200 and applies the update. The API takesif-matchon UpdateRetentionRule, DeleteRetentionRule, PutObjectLifecyclePolicy and DeleteObjectLifecyclePolicy (the SDK request types carryIfMatchfor all four), and buckets and objects here already enforce it with 412.- This matters most for retention rules, because the etag that Get and Update now return can't be used to guard a concurrent change.
- Fix: pass
If-Matchthrough to the provider and return 412IfMatchFailedon a mismatch, as UpdateBucket does.
OCI docs divergences (summary)
| Operation | Documented | Observed |
|---|---|---|
GetObject / HeadObject with versionId |
Content-MD5 or opc-multipart-md5 |
neither header |
| UpdateRetentionRule / DeleteRetentionRule | if-match mismatch returns 412 |
ignored |
| PutObjectLifecyclePolicy / DeleteObjectLifecyclePolicy | if-match mismatch returns 412 |
ignored |
Terraform and serve
- I did not run Terraform. The oracle/oci provider has no per-service endpoint override, and the HTTPS proxy route was not available in this environment. As the fallback I drove
serve --providers oci --oci-port <p>with the real oci-go-sdk through each operation behindoci_objectstorage_bucket'sretention_rules,oci_objectstorage_object,oci_objectstorage_preauthrequestand multipart uploads. - The earlier blocker for Terraform, a
retention_ruleschange on the bucket going throughUpdateRetentionRule, now succeeds with PUT and reads back the new duration. - A
--persist --persist-strategy on-shutdownrestart keeps multipart digests (on the original and on its copy), single-partContent-MD5, all four PARs with theirbucketListingAction, and working listing tokens. A PAR created after the restart lists correctly.
What I verified
- Head
8f7482aais one squashed commit on development4cc5c573, and it merges cleanly. The delta frombf70760eis the fixes above. The rest is the #421 wiring inproviders/oci/oci.goandserver/oci/oci.go, where both services are registered, plus regenerated coverage docs. go build ./...is clean.go vetis clean on providers/oci, server/oci, persist and serveflags. gofmt is clean.go generate ./...andgo mod tidyleave no diff.go test -racepasses on providers/oci/..., server/oci/..., persist/..., server/serveflags and internal/coveragegen.- golangci-lint with
--new-from-revon the touched packages reports 0 issues. - Coverage: providers/oci/objectstorage 96.2%, server/oci/objectstorage 96.6%.
- A concurrent probe across 12 goroutines is
-raceclean over 3 runs. It mixed puts, multipart create/upload/commit, HEAD, PAR listing, PUT retention updates, gets, snapshots and deletes. - CI: all checks are green.
Verdict
Approve. The High and both Mediums from the last round are fixed and hold up through the SDK and a real restart. The two Lows above are small follow-ups and can land here or separately.
|
|
||
| etag = details.ETag | ||
| } else { | ||
| stampInfoHeaders(w, &obj.Info) |
There was a problem hiding this comment.
With versionId set, GET (and HEAD at line 300) goes through stampInfoHeaders, which sets no MD5 header. Through the SDK, GetObject/HeadObject with VersionId return ContentMd5 = nil, while the same object read without versionId has it. The version chain already keeps contentMD5/multipartMD5, so expose them and call stampObjectMD5 on the versioned paths too.
| return | ||
| } | ||
|
|
||
| rule, err := h.extras.UpdateRetentionRule(r.Context(), bucket, ruleID, spec) |
There was a problem hiding this comment.
if-match is not checked here: UpdateRetentionRule with if-match: bogus returns 200 and applies the update. OCI takes if-match on Update/DeleteRetentionRule and Put/DeleteObjectLifecyclePolicy (all four SDK request types have IfMatch), and buckets and objects here already return 412. Pass If-Match through and return 412 IfMatchFailed on a mismatch. That way the etag that Get and Update now return can actually guard a concurrent change.
Summary
storagedriver.services/storage/driver— OCI-only behaviour is a consumer-sideExtrasinterface, per the rule set in Move OCI-only capabilities out of shared driver packages #393.Closes #410. Part of #376.
Changes
providers/oci/objectstorage/—Mockovermemstoreimplementingdriver.Bucket, guarded by async.RWMutex: namespace derivation, buckets, objects, versioning, multipart, retention, PARs.server/oci/objectstorage/— the/n/{namespace}/b/{bucket}/o/{object}surface plus PAR redemption at/p/{token}/n/….providers/oci/oci.goandserver/oci/oci.go.Operations
Namespace + metadata; bucket create/get/head/update/delete/list; object put/get/head/delete/list (prefix, delimiter, paging); rename and
updateObjectStorageTier; asynccopyObject; multipart create/upload/list-parts/commit/abort/list; object versions (Disabled/Enabled/Suspended, version-addressable GET/HEAD/DELETE, delete markers,objectversions); retention rules with lock semantics; lifecycle policy PUT/GET/DELETE; PARs including redemption.PARs are modelled as real resources
A PAR gets its own OCID,
timeExpires, an opaque redemption token, and is listable and revocable — not a fabricated signature.GeneratePresignedURLcreates one, so the URL it returns actually works and can be revoked. Demonstrated in the transcript: read-only PAR servesGET, refusesPUTwith 403, and 404s after revocation.Compartment scoping — one deliberate narrowing
Only
ListBucketscallsRequireCompartmentID; it is the one collection real OCI scopes by compartment. Object, PAR, retention and upload lists are bucket-scoped and take nocompartmentId, so requiring it would reject calls real OCI accepts.CreateBucketrequires it in the body. Stated in the package doc.Never accept-and-ignore
Rejected by name rather than silently dropped: bucket policies (→ Identity policies), CORS, object tags (→
opc-meta-),reencrypt,restoreObjects, multipartpartsToExclude, cross-namespace copy, more than one lifecycleinclusionPrefix, disabling encryption, versioning back toDisabled, and unknown access types / tiers / time units.reencryptis a 501 rather than a 202: with no per-object key material there is nothing to re-wrap, and a 202 there would be theatre.Provider Coverage
Checklist
go test ./...) — exit 0, 272 packagesgolangci-lint run --timeout=9m) — 0 issuescloudemu_test.go— driver + handler tests insteadTest Plan
Coverage leak check clean: no OCI operation appears in
docs/coverage/{aws,azure,gcp}/*.md, andgit diff development -- services/is empty.End-to-end on a running server (port 4611):
Note on the suite
Other Wave 2 branches see a pre-existing failure in
cmd/cloudemu TestServeOutOfProcess("AWS endpoint never became ready") which reproduces on cleandevelopment— it spawns a child process that cannot bind a listener in a sandbox. It passed on this run; flagging it as flaky-environmental rather than related to this change.