Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions contrib/realengine/blobstore/blobstore.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Package blobstore provides an opt-in real object-storage engine that persists
// object bytes to a real local filesystem — no Docker — backing CloudEmu's
// object stores (AWS S3, Azure Blob, GCP GCS). Bytes are written to real files
// under a root directory, so they survive in the store for the process's
// lifetime and can be inspected with ordinary tools. Wire it in with
// config.WithStorageEngine(blobstore.New("")).
// object stores (AWS S3, Azure Blob, GCP GCS, OCI Object Storage). Bytes are
// written to real files under a root directory, so they survive in the store
// for the process's lifetime and can be inspected with ordinary tools. Wire it
// in with config.WithStorageEngine(blobstore.New("")).
//
// It lives in a separate module on purpose: the storage-backing dependency
// stays out of CloudEmu's core. The in-memory provider keeps each object's
Expand Down
148 changes: 148 additions & 0 deletions contrib/realengine/blobstore/oci_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package blobstore_test

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

cloudemu "github.com/stackshy/cloudemu/v2"
"github.com/stackshy/cloudemu/v2/config"
"github.com/stackshy/cloudemu/v2/contrib/realengine/blobstore"
ociserver "github.com/stackshy/cloudemu/v2/server/oci"
)

const ociCompartment = "ocid1.compartment.oc1..aaaaaaaablobstore"

// ociCall issues one Object Storage request against the emulator and fails the
// test on any non-2xx.
func ociCall(t *testing.T, ts *httptest.Server, method, path string, body []byte) []byte {
t.Helper()

var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}

req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, reader)
if err != nil {
t.Fatalf("build %s %s: %v", method, path, err)
}

resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()

out, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s %s: %v", method, path, err)
}

if resp.StatusCode/100 != 2 {
t.Fatalf("%s %s: status %d: %s", method, path, resp.StatusCode, out)
}

return out
}

// TestOCIObjectStorageBlobstoreE2E runs the real-user flow against OCI Object
// Storage backed by a real filesystem engine (no Docker, no cloud account):
// read the namespace, create a bucket, put an object, get it, head it, copy it
// with the rename action, delete the original and confirm it is gone — then
// read the surviving bytes straight off disk under the engine root, proving
// they flowed through the engine rather than living only in memory.
//
// The requests are hand-built rather than driven by github.com/oracle/oci-go-sdk
// because that client mandates a signed request with an RSA keypair and a
// ConfigurationProvider, which the emulator does not verify; the wire shape is
// what this test is about.
func TestOCIObjectStorageBlobstoreE2E(t *testing.T) {
eng := blobstore.New("")
t.Cleanup(func() { _ = eng.Close() })

cloud := cloudemu.NewOCI(
config.WithStorageEngine(eng),
config.WithCompartmentID(ociCompartment),
)
ts := httptest.NewServer(ociserver.New(ociserver.Drivers{
ObjectStorage: cloud.ObjectStorage,
CompartmentID: cloud.CompartmentID,
TenancyOCID: cloud.TenancyOCID,
Region: cloud.Region,
}))
t.Cleanup(ts.Close)

var namespace string
if err := json.Unmarshal(ociCall(t, ts, http.MethodGet, "/n", nil), &namespace); err != nil {
t.Fatalf("decode namespace: %v", err)
}

const (
bucket = "blob-bucket"
object = "docs/greeting.txt"
moved = "docs/greeting-moved.txt"
)

body := []byte("hello from the real blobstore engine")
root := "/n/" + namespace + "/b"

spec, err := json.Marshal(map[string]string{"name": bucket, "compartmentId": ociCompartment})
if err != nil {
t.Fatalf("marshal bucket spec: %v", err)
}

ociCall(t, ts, http.MethodPost, root, spec)
ociCall(t, ts, http.MethodPut, root+"/"+bucket+"/o/"+object, body)

if got := ociCall(t, ts, http.MethodGet, root+"/"+bucket+"/o/"+object, nil); !bytes.Equal(got, body) {
t.Fatalf("object round-trip mismatch: got %q want %q", got, body)
}

var listed struct {
Objects []struct {
Name string `json:"name"`
Size int64 `json:"size"`
} `json:"objects"`
}

if err := json.Unmarshal(ociCall(t, ts, http.MethodGet, root+"/"+bucket+"/o", nil), &listed); err != nil {
t.Fatalf("decode list: %v", err)
}

if len(listed.Objects) != 1 || listed.Objects[0].Size != int64(len(body)) {
t.Fatalf("list must report the real size after the offload: %+v", listed.Objects)
}

rename, err := json.Marshal(map[string]string{"sourceName": object, "newName": moved})
if err != nil {
t.Fatalf("marshal rename: %v", err)
}

ociCall(t, ts, http.MethodPost, root+"/"+bucket+"/actions/renameObject", rename)

if got := ociCall(t, ts, http.MethodGet, root+"/"+bucket+"/o/"+moved, nil); !bytes.Equal(got, body) {
t.Fatalf("renamed object mismatch: got %q want %q", got, body)
}

req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL+root+"/"+bucket+"/o/"+object, nil)
if err != nil {
t.Fatalf("build get: %v", err)
}

resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("get deleted source: %v", err)
}

_ = resp.Body.Close()

if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 for the renamed-away source, got %d", resp.StatusCode)
}

assertEngineFileMatches(t, eng, bucket, moved, body)
}
2 changes: 1 addition & 1 deletion docs/coverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json).
| `sql` | — | [SQL](./azure/sql.md) | — | — | 21 |
| `sqlvirtualmachine` | — | [SQLVirtualMachine](./azure/sqlvirtualmachine.md) | — | — | 9 |
| `sshpublickeys` | — | [Sshpublickeys](./azure/sshpublickeys.md) | — | — | 7 |
| `storage` | [S3](./aws/s3.md) | [BlobStorage](./azure/blobstorage.md) | [GCS](./gcp/gcs.md) | | 35 |
| `storage` | [S3](./aws/s3.md) | [BlobStorage](./azure/blobstorage.md) | [GCS](./gcp/gcs.md) | [ObjectStorage](./oci/objectstorage.md) | 35 |
| `storageaccount` | — | [Storageaccount](./azure/storageaccount.md) | — | — | 10 |
| `sts` | [STS](./aws/sts.md) | — | — | — | 8 |
| `subscriptions` | — | [Subscriptions](./azure/subscriptions.md) | — | — | 3 |
Expand Down
3 changes: 2 additions & 1 deletion docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -13361,7 +13361,8 @@
"providers": {
"aws": "S3",
"azure": "BlobStorage",
"gcp": "GCS"
"gcp": "GCS",
"oci": "ObjectStorage"
}
},
{
Expand Down
1 change: 1 addition & 0 deletions docs/coverage/oci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider
| --- | --- | --- |
| [Identity](./identity.md) | `iam` | 40 |
| [Monitoring](./monitoring.md) | `monitoring` | 12 |
| [ObjectStorage](./objectstorage.md) | `storage` | 35 |
| [VCN](./vcn.md) | `networking` | 57 |
| [Workrequest](./workrequest.md) | — (provider-native) | 4 |
100 changes: 100 additions & 0 deletions docs/coverage/oci/objectstorage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!-- Generated by `go generate ./...` (internal/coveragegen). Do not edit by hand. -->
# ObjectStorage

OCI's `storage` service · portable interface `driver.Bucket` · [OCI index](./README.md)

## Operations (35)

| Operation | Description |
| --- | --- |
| `AbortMultipartUpload` | |
| `CompleteMultipartUpload` | |
| `CopyObject` | |
| `CreateBucket` | |
| `CreateMultipartUpload` | Multipart uploads |
| `DeleteBucket` | |
| `DeleteBucketPolicy` | |
| `DeleteBucketTagging` | |
| `DeleteCORSConfig` | |
| `DeleteObject` | |
| `DeleteObjectTagging` | |
| `EvaluateLifecycle` | |
| `GeneratePresignedURL` | Presigned URLs |
| `GetBucketPolicy` | |
| `GetBucketTagging` | |
| `GetBucketVersioning` | |
| `GetCORSConfig` | |
| `GetEncryptionConfig` | |
| `GetLifecycleConfig` | |
| `GetObject` | |
| `GetObjectTagging` | |
| `HeadObject` | |
| `ListBuckets` | |
| `ListMultipartUploads` | |
| `ListObjects` | |
| `ListParts` | ListParts returns the parts buffered so far for an in-progress upload, |
| `PutBucketPolicy` | Bucket Policy |
| `PutBucketTagging` | Bucket Tagging |
| `PutCORSConfig` | CORS |
| `PutEncryptionConfig` | Encryption |
| `PutLifecycleConfig` | Lifecycle policies |
| `PutObject` | |
| `PutObjectTagging` | Object Tagging |
| `SetBucketVersioning` | Versioning |
| `UploadPart` | |

## Optional capabilities

Discovered by type assertion; only some providers implement these.

### VersionedBucket

VersionedBucket is an optional extension a storage provider implements when

| Operation | Description |
| --- | --- |
| `AbortMultipartUpload` | |
| `CompleteMultipartUpload` | |
| `CopyObject` | |
| `CreateBucket` | |
| `CreateMultipartUpload` | Multipart uploads |
| `DeleteBucket` | |
| `DeleteBucketPolicy` | |
| `DeleteBucketTagging` | |
| `DeleteCORSConfig` | |
| `DeleteObject` | |
| `DeleteObjectTagging` | |
| `DeleteObjectVersion` | DeleteObjectVersion removes a specific version when versionID != "". |
| `EvaluateLifecycle` | |
| `GeneratePresignedURL` | Presigned URLs |
| `GetBucketPolicy` | |
| `GetBucketTagging` | |
| `GetBucketVersioning` | |
| `GetCORSConfig` | |
| `GetEncryptionConfig` | |
| `GetLifecycleConfig` | |
| `GetObject` | |
| `GetObjectTagging` | |
| `GetObjectVersion` | GetObjectVersion / HeadObjectVersion fetch a specific version by ID. A |
| `HeadObject` | |
| `HeadObjectVersion` | |
| `ListBuckets` | |
| `ListMultipartUploads` | |
| `ListObjectVersions` | ListObjectVersions returns the full version history matching opts. |
| `ListObjects` | |
| `ListParts` | ListParts returns the parts buffered so far for an in-progress upload, |
| `PutBucketPolicy` | Bucket Policy |
| `PutBucketTagging` | Bucket Tagging |
| `PutCORSConfig` | CORS |
| `PutEncryptionConfig` | Encryption |
| `PutLifecycleConfig` | Lifecycle policies |
| `PutObject` | |
| `PutObjectTagging` | Object Tagging |
| `SetBucketVersioning` | Versioning |
| `SetVersioningStatus` | SetVersioningStatus sets the bucket's versioning status: "Enabled" or |
| `UploadPart` | |
| `VersioningStatus` | |

## Not in scope

_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._
75 changes: 74 additions & 1 deletion docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ This document lists every service and operation available in CloudEmu across all
## 1. Storage

**Driver interface:** `services/storage/driver/driver.go`
**AWS:** S3 | **Azure:** Blob Storage | **GCP:** GCS
**AWS:** S3 | **Azure:** Blob Storage | **GCP:** GCS | **OCI:** Object Storage (buckets live in a compartment under the tenancy namespace; pre-authenticated requests map to presigned URLs; retention rules and storage tiers have no portable equivalent — bucket policies, CORS and object tags are not OCI concepts and answer `Unimplemented`)

### Bucket Operations

Expand Down Expand Up @@ -159,6 +159,79 @@ This document lists every service and operation available in CloudEmu across all

**Total: 33 operations**

### OCI Object Storage

**Optional capability:** `server/oci/objectstorage.Extras` — OCI roots every
path at the tenancy namespace, scopes buckets to a compartment, and carries
bucket settings, object rename, storage tiers, retention rules and
pre-authenticated requests that the portable interface does not express. Its
value types live in `providers/oci/objectstorage`.
**Provider:** `providers/oci/objectstorage` | **Wire:** `server/oci/objectstorage`

Object Storage carries no API-version prefix; `{ns}` is the tenancy namespace,
which `GET /n` returns.

| Operation | Route |
|-----------|-------|
| `GetNamespace` | `GET /n` |
| `GetNamespaceMetadata` | `GET /n/{ns}` |
| `CreateBucket` | `POST /n/{ns}/b` |
| `ListBuckets` | `GET /n/{ns}/b` |
| `GetBucket` | `GET /n/{ns}/b/{bucket}` |
| `HeadBucket` | `HEAD /n/{ns}/b/{bucket}` |
| `UpdateBucket` | `POST /n/{ns}/b/{bucket}` |
| `DeleteBucket` | `DELETE /n/{ns}/b/{bucket}` |
| `ListObjects` | `GET /n/{ns}/b/{bucket}/o` |
| `PutObject` | `PUT /n/{ns}/b/{bucket}/o/{object}` |
| `GetObject` | `GET /n/{ns}/b/{bucket}/o/{object}` |
| `HeadObject` | `HEAD /n/{ns}/b/{bucket}/o/{object}` |
| `DeleteObject` | `DELETE /n/{ns}/b/{bucket}/o/{object}` |
| `ListObjectVersions` | `GET /n/{ns}/b/{bucket}/objectversions` |
| `RenameObject` | `POST /n/{ns}/b/{bucket}/actions/renameObject` |
| `CopyObject` | `POST /n/{ns}/b/{bucket}/actions/copyObject` |
| `UpdateObjectStorageTier` | `POST /n/{ns}/b/{bucket}/actions/updateObjectStorageTier` |
| `CreateMultipartUpload` | `POST /n/{ns}/b/{bucket}/u` |
| `ListMultipartUploads` | `GET /n/{ns}/b/{bucket}/u` |
| `UploadPart` | `PUT /n/{ns}/b/{bucket}/u/{object}` |
| `CommitMultipartUpload` | `POST /n/{ns}/b/{bucket}/u/{object}` |
| `ListMultipartUploadParts` | `GET /n/{ns}/b/{bucket}/u/{object}` |
| `AbortMultipartUpload` | `DELETE /n/{ns}/b/{bucket}/u/{object}` |
| `CreatePreauthenticatedRequest` | `POST /n/{ns}/b/{bucket}/p` |
| `ListPreauthenticatedRequests` | `GET /n/{ns}/b/{bucket}/p` |
| `GetPreauthenticatedRequest` | `GET /n/{ns}/b/{bucket}/p/{parId}` |
| `DeletePreauthenticatedRequest` | `DELETE /n/{ns}/b/{bucket}/p/{parId}` |
| PAR redemption | `GET`/`PUT /p/{par}/n/{ns}/b/{bucket}/o/{object}` |
| `CreateRetentionRule` | `POST /n/{ns}/b/{bucket}/retentionRules` |
| `ListRetentionRules` | `GET /n/{ns}/b/{bucket}/retentionRules` |
| `GetRetentionRule` | `GET /n/{ns}/b/{bucket}/retentionRules/{ruleId}` |
| `UpdateRetentionRule` | `PUT /n/{ns}/b/{bucket}/retentionRules/{ruleId}` |
| `DeleteRetentionRule` | `DELETE /n/{ns}/b/{bucket}/retentionRules/{ruleId}` |
| `PutObjectLifecyclePolicy` | `PUT /n/{ns}/b/{bucket}/l` |
| `GetObjectLifecyclePolicy` | `GET /n/{ns}/b/{bucket}/l` |
| `DeleteObjectLifecyclePolicy` | `DELETE /n/{ns}/b/{bucket}/l` |

`ListBuckets` is the one collection OCI scopes by compartment, so it is the
only route here that requires `compartmentId`; every other list is scoped by
its bucket. An unspecified `limit` on `ListObjects` yields OCI's page size of
1000, not the 100 the other OCI services default to. `copyObject` is
asynchronous in real OCI, so it returns `202` with an `opc-work-request-id` the
shared work-request poller answers; every other mutation here is synchronous.

Buckets refuse deletion while they hold objects or uncommitted multipart
uploads. Versioning is the OCI tri-state — `Disabled`, `Enabled`, `Suspended` —
and never returns to `Disabled` once enabled; a `Suspended` bucket reuses the
`null` version rather than appending. Retention rules with an elapsed lock
block overwrites and deletes, and a locked rule cannot be weakened.

Object bytes flow through `config.WithStorageEngine` when one is wired, the
same seam AWS S3, Azure Blob and GCP GCS use, keyed by object version so each
version's bytes are addressed separately.

Not emulated: `/actions/reencrypt` and `/actions/restoreObjects`, which need
per-object key material and an archive-retrieval lifecycle the storage driver
has no shape for. Both are claimed by the handler and answer `501` with the
reason rather than a bare `404`.

---

## 2. Compute
Expand Down
1 change: 1 addition & 0 deletions docs/standalone-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ your client.
| `--aws-port` / `--azure-port` / `--gcp-port` / `--k8s-port` / `--oci-port` | `4566`/`4568`/`4569`/`4570`/`4571` | listen ports (empty `--k8s-port` disables Kubernetes; OCI only served when `oci` is in `--providers`) |
| `--account-id` | `000000000000` | AWS account ID (also used for GCP/OCI) |
| `--azure-subscription` | `00000000-0000-0000-0000-000000000000` | Azure subscription id (a GUID). Resource ids and Resource Graph scoping use it; discovery is subscription-transparent, so a query scoped to any subscription returns the estate rendered under it |
| `--oci-tenancy` | `ocid1.tenancy.oc1..aaaaaaaacloudemulocaltenancy` | OCI tenancy OCID. It is also the root compartment, and the Object Storage namespace is derived from it |
| `--region` | `us-east-1` | default region |
| `--project-id` | `cloudemu-local` | GCP project ID |
| `--latency` | `0` | artificial per-call latency (e.g. `20ms`) |
Expand Down
Loading
Loading