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 @@ -169,7 +169,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 |
| `streamanalytics` | - | [StreamAnalytics](./azure/streamanalytics.md) | - | - | 19 |
| `sts` | [STS](./aws/sts.md) | - | - | - | 8 |
Expand Down
3 changes: 2 additions & 1 deletion docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -18084,7 +18084,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 |
101 changes: 101 additions & 0 deletions docs/coverage/oci/objectstorage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<!-- 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

- Azure: on the bare emulator host (such as `https://127.0.0.1:4568/`), Blob, Queue and Table share one endpoint, and the account-root calls (`GET /?comp=list`, `?restype=service`, `?restype=account`) look the same for each service. They go to Blob unless the User-Agent carries an Azure SDK Queue or Table product token (`azsdk-go-azqueue`, `azsdk-python-storage-queue`, `azsdk-java-azure-storage-queue`, `azsdk-js-storage-queue`, `azsdk-net-Storage.Queues`, and the matching `data-tables` clients). Other Queue and Table clients should use the `{account}.queue.core.windows.net` or `{account}.table.core.windows.net` host, or the path-style `/{account}/` form.
- Azure: Queue and Table Set Service Properties validate the request and return 202, but do not store logging, metrics or CORS settings. Get Service Stats and Get User Delegation Key are not served.
Loading
Loading