Skip to content

Add OCI Object Storage: buckets, objects, multipart, PARs - #422

Open
arunesh-j wants to merge 2 commits into
developmentfrom
feat/oci-objectstorage
Open

Add OCI Object Storage: buckets, objects, multipart, PARs#422
arunesh-j wants to merge 2 commits into
developmentfrom
feat/oci-objectstorage

Conversation

@arunesh-j

Copy link
Copy Markdown
Collaborator

Summary

  • Implements OCI Object Storage against the existing portable storage driver.
  • Namespaces, buckets, objects, multipart, versioning, retention rules, lifecycle policies, and Pre-Authenticated Requests.
  • Nothing added to services/storage/driver — OCI-only behaviour is a consumer-side Extras interface, per the rule set in Move OCI-only capabilities out of shared driver packages #393.

Closes #410. Part of #376.

Changes

  • providers/oci/objectstorage/Mock over memstore implementing driver.Bucket, guarded by a sync.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/….
  • Wiring is one line each in providers/oci/oci.go and server/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; async copyObject; 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. GeneratePresignedURL creates one, so the URL it returns actually works and can be revoked. Demonstrated in the transcript: read-only PAR serves GET, refuses PUT with 403, and 404s after revocation.

Compartment scoping — one deliberate narrowing

Only ListBuckets calls RequireCompartmentID; it is the one collection real OCI scopes by compartment. Object, PAR, retention and upload lists are bucket-scoped and take no compartmentId, so requiring it would reject calls real OCI accepts. CreateBucket requires 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, multipart partsToExclude, cross-namespace copy, more than one lifecycle inclusionPrefix, disabling encryption, versioning back to Disabled, and unknown access types / tiers / time units.

reencrypt is 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

  • AWS
  • Azure
  • GCP
  • OCI

Checklist

  • All tests pass (go test ./...) — exit 0, 272 packages
  • Linter passes (golangci-lint run --timeout=9m) — 0 issues
  • Every provider the change applies to implements the same behavior — OCI-only, additive
  • Integration tests added to cloudemu_test.go — driver + handler tests instead
  • Unit tests added to provider test files

Test Plan

go build ./...                                              clean
go test ./...                                               exit 0, 272 packages
go test -race ./providers/oci/... ./server/oci/...          11/11 ok
golangci-lint run --timeout=9m ./providers/oci/... ./server/oci/...   0 issues
go generate ./...                                           docs/coverage committed

Coverage leak check clean: no OCI operation appears in docs/coverage/{aws,azure,gcp}/*.md, and git diff development -- services/ is empty.

End-to-end on a running server (port 4611):

GET  /n                                     -> namespace 30aa137d4542
POST /n/{ns}/b                              -> 200, ocid1.bucket.oc1.iad..., etag, Standard tier
PUT  /o/holiday/beach.txt (opc-meta-owner)  -> 200, Etag + Opc-Content-Md5 + Opc-Meta-Owner
GET  /o/holiday/beach.txt                   -> 200 "sun and sea", metadata round-trips
GET  /o?prefix=holiday/&delimiter=/         -> 200, one object
GET  /b?compartmentId=...                   -> 200, one bucket
delete object / bucket / get deleted        -> 204, 204, 404

POST /n/{ns}/b/docs/p                       -> 200, PAR with accessUri
GET  /p/{token}/n/{ns}/b/docs/o/report.txt  -> 200 "quarterly"
PUT  (same URI, read-only PAR)              -> 403
DELETE the PAR, then GET                    -> 204, then 404
POST /b/docs/actions/copyObject             -> 202 + Opc-Work-Request-Id
poll work request                           -> SUCCEEDED, COPY_OBJECT, object CREATED

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 clean development — 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.

@arunesh-j arunesh-j added the oci Oracle Cloud Infrastructure label Aug 21, 2026

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Implements OCI Object Storage against the portable storage driver.

providers/oci/objectstorage holds the Mock over memstore.Store, satisfying
driver.Bucket and the optional driver.VersionedBucket. Buckets carry OCI's
settings (public access type, storage tier, versioning tri-state, KMS key,
auto-tiering) and record the compartment they were created in. Objects carry
opc-meta- user metadata, a content MD5 and a per-object storage tier.
Retention rules hold objects against delete and overwrite, and a locked rule
can only be extended. A pre-authenticated request is a first-class resource
with its own OCID, lifetime and redemption token, so GeneratePresignedURL
creates a real PAR that ListPARs and DeletePAR can see and revoke.

server/oci/objectstorage serves the /n/{namespace}/b/{bucket}/o/{object}
surface, plus multipart uploads, object versions, retention rules, the
lifecycle policy, PAR management and PAR redemption at /p/{token}/n/…. The
OCI-only behaviour is a consumer-side Extras interface satisfied by the mock;
a driver that does not satisfy it is served 501. ListBuckets requires
compartmentId; the bucket-scoped lists take what real OCI takes. copyObject
is asynchronous and records a work request, as real OCI does.

Operations with no OCI equivalent are named rather than silently accepted:
bucket policies (Identity policies do that), CORS, object tags (objects carry
opc-meta- metadata), reencrypt and restoreObjects, and multipart
partsToExclude.
… add persistence

Review follow-ups on the OCI Object Storage PR.

GetNamespaceMetadata misrouted to ListBuckets whenever the tenancy namespace
began with "b": serveBucketCollection separated /n/{ns} from /n/{ns}/b by
sniffing the raw path for the substring "/b", so /n/b25a97828eed matched and
fell through to listBuckets, which 400s without compartmentId. parseNamespaced
now carries whether a /b segment was actually parsed.

HeadObject reported neither Content-Length nor the object's Content-Type: a HEAD
carries no body, so a client had no way to learn the size. It now answers
through its own writer rather than the JSON helper, whose application/json was
overwriting the object's type.

ListObjects applied ocirest.DefaultLimit (100) when the caller named no limit,
so the provider's own 1000 — real OCI's page size — was unreachable from the
wire. An absent limit is now left for the provider to fill in, leaving
DefaultLimit alone for the other OCI services.

Object bytes now flow through config.WithStorageEngine, the seam AWS S3, Azure
Blob and GCP GCS already use, keyed by object version so each version is
addressed separately. Object and version records track Size independently, so
Head, List and a bucket's approximate size stay correct once the bytes are
offloaded.

Adds Snapshottable, which persist's completeness guard (#582) now requires:
buckets, objects, version chains, PARs (with their redemption tokens, so an
access URI issued before a snapshot still redeems), retention rules and the
lifecycle policy round-trip under their original identities.

Object Storage metrics were silently dropped: the provider publishes to
oci_objectstorage, and the OCI Monitoring mock refused any Oracle-reserved
namespace on every path. The reservation now applies to PostMetricData, the
customer-facing one, and not to the seam Oracle's own emulated services use.

Adds --oci-tenancy so the tenancy — and therefore the Object Storage namespace
derived from it — is reachable from the CLI, as the AWS account, Azure
subscription and GCP project already are.

Documents the service in docs/services.md, and raises coverage to 93.9%
(provider) and 94.9% (wire) from 66.2% and 71.6%.
@arunesh-j
arunesh-j force-pushed the feat/oci-objectstorage branch from b85c29f to f285446 Compare September 5, 2026 19:13
@arunesh-j

Copy link
Copy Markdown
Collaborator Author

Rebased onto current development (now 58787e9a) and addressed the findings in f2854465.

High — GET /n/{ns} misrouted when the namespace starts with b

You were right about the cause and the fix. route now carries HasBucketSeg, set in parseNamespaced when a /b segment is genuinely parsed; serveBucketCollection branches on that instead of strings.Contains(r.URL.Path, "/b").

Regression test TestNamespaceMetadataWithBPrefixedNamespace uses your exact repro tenancy ocid1.tenancy.oc1..probe0100 → namespace b25a97828eed. Reverting the fix:

--- FAIL: TestNamespaceMetadataWithBPrefixedNamespace
    expected: 200   actual: 400
    {"code":"InvalidParameter","message":"compartmentId is required"}

Confirmed live on the built binary, including that the collection is not over-corrected:

GET /n                  -> "b25a97828eed"
GET /n/b25a97828eed     -> 200 {"namespace":"b25a97828eed","defaultS3CompartmentId":...}
GET /n/b25a97828eed/b   -> 400 compartmentId is required   (still required, as it should be)

Medium — docs/services.md

Added an OCI Object Storage subsection under §1 Storage in the VCN/Monitoring/Identity format: a 36-row operation→route table plus prose on compartment-scoped ListBuckets, the 1000 page size, async copyObject, the versioning tri-state, retention locking, the storage-engine seam, and the two unemulated actions.

Medium — coverage

66.2% → 93.9% provider, 71.6% → 94.9% wire. Every gap you named is covered: EvaluateLifecycle/objectExpired via a config.FakeClock test that steps to one hour short of the window (asserts nothing expires) and then past it (asserts exactly the enabled logs/ names), plus HeadObjectVersion and a portable SetBucketVersioning/GetBucketVersioning round-trip.

Low — ListObjects page size

listLimit returns 0 when no limit is present, letting the provider's defaultListLimit (1000) apply. ocirest.DefaultLimit is untouched for other services. Live: 120 objects with no limit → all 120 and an empty cursor; limit=50 → 50 plus a cursor.

Low — SDK-compat test: not added, deliberately

oracle/oci-go-sdk/v65 requires a signed request built from an RSA keypair and a ConfigurationProvider. This emulator verifies no signature, so the client would mostly exercise its own signing rather than the handler's wire shape, and every sibling OCI service here is tested with hand-rolled httptest requests. I judged the dependency disproportionate. Instead I added contrib/realengine/blobstore/oci_test.go — a real over-HTTP E2E through httptest.NewServer plus the filesystem engine — with that reasoning recorded in its doc comment. Happy to revisit if you'd rather have the SDK dependency.

On the "engines N/A" note — I think Object Storage is engine-eligible

Your N/A referred to #427's compute-shaped engines, but services/storage/storageengine is a different seam, and its own package doc named AWS/Azure/GCP with OCI the only storage provider not wired to it. Now wired in providers/oci/objectstorage/engine.go across put, get, delete, rename, copy, multipart-commit and every versioned path, keyed by version id like S3.

Two design notes: the engine call happens under the write lock (S3 nils the byte slice after releasing it, which races a concurrent reader), and objectData/objectVersion gained a Size field so Head/List/approximateSize stay correct once bytes are offloaded.

Three defects found that were not in the review

  1. Object Storage metrics were silently dropped. The provider publishes to oci_objectstorage; providers/oci/monitoring rejected any oci_-prefixed namespace as Oracle-reserved, and emitMetric discards its error — so no metric was ever recorded. The reservation exists to stop customer posts, so it now applies to PostMetricData (customer-facing) and not to PutMetricData (the seam Oracle's own emulated services use). This touches an already-merged service, so say the word and I'll split it into its own PR.
  2. HeadObject returned no Content-Length and Content-Type: application/json (inherited from the JSON helper). A HEAD has no body, so SDKs read the size from the header. Now served by a dedicated writeHead.
  3. Persistence guard. Upstream Evolve persist/ to per-driver, identity-preserving snapshotting (full-surface) #582 added TestSnapshotCompleteness, which failed the suite for this provider. Added snapshot.go plus round-trip tests covering buckets, objects, version chains, PARs with their redemption tokens, retention rules and lifecycle — so an access URI issued before a snapshot still redeems after restore.

One scope addition for your call

The tenancy was unreachable from the CLI (AWS account-id, Azure subscription and GCP project all are), so the b-prefixed namespace could not be driven through the shipped binary at all. Added --oci-tenancy (server/serveflags, its pinned flag-name guard, and docs/standalone-server.md). Cleanly separable if you'd rather keep this PR narrow.

Verification

go build ./...                                   clean
go test ./...                                    exit 0, 342 packages
go test -race ./providers/oci/... ./server/oci/...   11/11 ok
golangci-lint (all changed packages)             0 issues
go test -cover  provider 93.9%   wire 94.9%
go generate ./...                                docs/coverage committed
no OCI op in docs/coverage/{aws,azure,gcp}/*.md  confirmed

The only remaining lint in ./providers/oci/... is 7 pre-existing gocritic findings in vcn/nsg.go and identity/portable.go, which reproduce on clean development.

E2E on port 4611 also exercised: bucket CRUD and the versioning:"On" rejection, object put/get/head/list with prefix+delimiter, version list / read-by-version / delete markers, rename, async copy (202 + opc-work-request-id), storage tier, both 501s, multipart including the partsToExclude 501, PARs (redeem, write-refused, wrong-object refused, list, delete), retention (create/get/update, MONTHS refused, object held, delete) and lifecycle (YEARS→365 days, unknown action refused, multi-prefix refused). Happy to paste the full transcript if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oci Oracle Cloud Infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OCI Object Storage: buckets, objects, multipart

2 participants