Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/coverage/aws/s3.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,5 @@ VersionedBucket is an optional extension a storage provider implements when

## Not in scope

_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._
- 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.
3 changes: 2 additions & 1 deletion docs/coverage/azure/blobstorage.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,5 @@ StorageAccountKeys is an OPTIONAL Azure-specific capability, discovered by

## Not in scope

_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._
- 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.
3 changes: 2 additions & 1 deletion docs/coverage/gcp/gcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ GCSExtensions is an OPTIONAL GCS-specific capability, discovered by type

## Not in scope

_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._
- 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.
2 changes: 2 additions & 0 deletions docs/coverage/nongoals/storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- 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.
14 changes: 12 additions & 2 deletions providers/azure/eventgrid/delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,19 @@ func (m *Mock) dispatchStorageQueue(ctx context.Context, dest subscriptionDestin
return
}

if dest.QueueName != "" {
_ = m.storageQueue.DeliverExternal(ctx, dest.QueueName, string(body))
if dest.QueueName == "" {
return
}

// A queue in a storage account is keyed "{account}/{queue}" (the default
// account's queues by bare name), so try the account named by resourceId
// first and fall back to the default account.
if acct := resourceLeafName(dest.ResourceID); acct != "" &&
m.storageQueue.DeliverExternal(ctx, acct+"/"+dest.QueueName, string(body)) == nil {
return
}

_ = m.storageQueue.DeliverExternal(ctx, dest.QueueName, string(body))
}

// resourceLeafName returns the trailing path segment of an ARM resource id, the
Expand Down
3 changes: 3 additions & 0 deletions providers/azure/servicebus/servicebus.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,9 @@ func buildSendMessage(input *driver.SendMessageInput, sessionID string, now time
}

visibleAt := now.Add(time.Duration(delaySeconds) * time.Second)
if input.ScheduledEnqueueTime.After(visibleAt) {
visibleAt = input.ScheduledEnqueueTime
}

return &sbMessage{
ID: idgen.GenerateID("sb-msg-"),
Expand Down
8 changes: 6 additions & 2 deletions server/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -1237,8 +1237,12 @@ func New(d Drivers) http.Handler {
// that contain parentheses or a bare JSON POST, disjoint from Blob's
// container/blob paths and Queue's /messages surface. Registered before the
// permissive Blob fallback.
// Storage accounts scope the queue and table namespaces the same way they
// scope blob containers.
storageAccounts, _ := d.BlobStorage.(storagedriver.AzureStorageAccounts)

if d.TableStorage != nil {
srv.Register(tablesrv.New(d.TableStorage))
srv.Register(tablesrv.New(d.TableStorage).WithAccounts(storageAccounts))
}

// Queue Storage matches the queue data-plane surface (/{queue}/messages,
Expand All @@ -1247,7 +1251,7 @@ func New(d Drivers) http.Handler {
// carries OData parentheses). Registered before the permissive Blob
// fallback.
if d.QueueStorage != nil {
srv.Register(queue.New(d.QueueStorage))
srv.Register(queue.New(d.QueueStorage).WithAccounts(storageAccounts))
}

// Storage-account ARM control plane (Microsoft.Storage/storageAccounts).
Expand Down
2 changes: 2 additions & 0 deletions server/azure/blobstorage/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.listContainers(w, r, account)
case container == "" && q.Get("comp") == compBlobs:
h.findBlobsByTags(w, r, account, "")
case container == "" && azurearm.IsStorageServiceOp(q):
h.serviceOp(w, r, account)
case container == "":
writeError(w, http.StatusNotImplemented, "NotImplemented", "operation not supported on root")
case blob == "" && q.Get("restype") == "container":
Expand Down
116 changes: 116 additions & 0 deletions server/azure/blobstorage/service_properties.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package blobstorage

import (
"net/http"
"strings"

"github.com/stackshy/cloudemu/v2/server/wire/azurearm"
storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver"
)

// serviceOp serves the account-level Blob service operations: Get/Set Blob
// Service Properties (?restype=service&comp=properties) and Get Account
// Information (?restype=account&comp=properties). The delete retention policy
// and CORS rules share their state with the ARM blobServices/default resource,
// so a value set on either surface reads back on the other.
func (h *Handler) serviceOp(w http.ResponseWriter, r *http.Request, account string) {
cfg, ok := h.bucket.(storagedriver.BlobServiceConfig)
if !ok || !azurearm.IsStorageServicePropertiesOp(r.URL.Query()) {
azurearm.ServeStorageServiceOp(w, r)
return
}

if account == "" {
account = storagedriver.AzureDefaultStorageAccount
}

current, err := cfg.BlobServiceProperties(r.Context(), account)
if err != nil {
writeErr(w, err)
return
}

switch r.Method {
case http.MethodGet:
props := toWireServiceProperties(&current)
azurearm.WriteStorageServiceProperties(w, &props)
case http.MethodPut:
in, ok := azurearm.DecodeStorageServiceProperties(w, r)
if !ok {
return
}

mergeServiceProperties(&current, &in)

if err := cfg.SetBlobServiceProperties(r.Context(), account, current); err != nil {
writeErr(w, err)
return
}

w.WriteHeader(http.StatusAccepted)
default:
writeError(w, http.StatusMethodNotAllowed, "UnsupportedHttpVerb", "method not allowed")
}
}

// toWireServiceProperties renders the stored Blob service properties as the
// data-plane document, with the defaults for what cloudemu does not store.
func toWireServiceProperties(p *storagedriver.BlobServiceProperties) azurearm.StorageServiceProperties {
out := azurearm.DefaultStorageServiceProperties()
out.DeleteRetentionPolicy = &azurearm.StorageRetentionPolicy{
Enabled: p.DeleteRetentionEnabled,
Days: p.DeleteRetentionDays,
}

for _, c := range p.CORS {
out.Cors.Rules = append(out.Cors.Rules, azurearm.StorageCorsRule{
AllowedOrigins: strings.Join(c.AllowedOrigins, ","),
AllowedMethods: strings.Join(c.AllowedMethods, ","),
AllowedHeaders: strings.Join(c.AllowedHeaders, ","),
ExposedHeaders: strings.Join(c.ExposeHeaders, ","),
MaxAgeInSeconds: c.MaxAgeSeconds,
})
}

return out
}

// mergeServiceProperties applies a Set Blob Service Properties body. As in
// real Azure, an element the request omits keeps its current value.
func mergeServiceProperties(p *storagedriver.BlobServiceProperties, in *azurearm.StorageServiceProperties) {
if in.DeleteRetentionPolicy != nil {
p.DeleteRetentionEnabled = in.DeleteRetentionPolicy.Enabled
p.DeleteRetentionDays = 0

if in.DeleteRetentionPolicy.Enabled {
p.DeleteRetentionDays = in.DeleteRetentionPolicy.Days
}
}

if in.Cors != nil {
p.CORS = make([]storagedriver.CORSRule, 0, len(in.Cors.Rules))

for _, c := range in.Cors.Rules {
p.CORS = append(p.CORS, storagedriver.CORSRule{
AllowedOrigins: splitList(c.AllowedOrigins),
AllowedMethods: splitList(c.AllowedMethods),
AllowedHeaders: splitList(c.AllowedHeaders),
ExposeHeaders: splitList(c.ExposedHeaders),
MaxAgeSeconds: c.MaxAgeInSeconds,
})
}
}
}

// splitList splits a comma-separated CORS list, dropping empty entries.
func splitList(s string) []string {
var out []string

for _, v := range strings.Split(s, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}

return out
}
10 changes: 6 additions & 4 deletions server/azure/cosmosdb/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,12 @@ func (h *Handler) Matches(r *http.Request) bool {
return false
}

// A root "GET /?comp=list" is a Storage service call (list queues / list
// containers), not a Cosmos account probe (which carries no query). Decline
// it so the Queue and Blob handlers, registered after this one, serve it.
if rest == "/" && r.URL.Query().Get("comp") == "list" {
// A root request carrying comp= or restype= is a Storage account-level call
// (list queues or containers, service properties, account information,
// find blobs by tags), not a Cosmos account probe (which carries no query).
// Decline it so the Queue, Table and Blob handlers, registered after this
// one, serve it.
if q := r.URL.Query(); rest == "/" && (q.Has("comp") || q.Has("restype")) {
return false
}

Expand Down
102 changes: 102 additions & 0 deletions server/azure/queue/account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package queue

import (
"net/http"
"slices"
"strings"

"github.com/stackshy/cloudemu/v2/server/wire/azurearm"
storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver"
)

// WithAccounts scopes queues to the storage accounts in accounts: a queue
// created through {account}.queue.core.windows.net (or the path-style
// /{account}/ prefix) lives only in that account. Without it every request
// uses the default account.
func (h *Handler) WithAccounts(accounts storagedriver.AzureStorageAccounts) *Handler {
h.accounts = accounts
return h
}

// resolve returns the account a request targets and the path below it. The
// {account}.queue host names the account when it is an existing storage
// account; otherwise a path-style /{account}/ prefix does. The default account
// "cloudemu" and the bare host map to the default namespace (""), which also
// holds every queue from before accounts were modeled.
func (h *Handler) resolve(r *http.Request) (account, path string) {
if acct, svc, ok := azurearm.StorageHost(r.Host); ok && svc == azurearm.StorageServiceQueue && h.accountExists(r, acct) {
return acct, r.URL.Path
}

q := r.URL.Query()
rootOp := q.Get("comp") == compList || azurearm.IsStorageServiceOp(q)

return azurearm.PeelStorageAccount(r.URL.Path, rootOp, func(name string) bool {
// A default-namespace queue of the same name keeps its URL.
return h.accountExists(r, name) && !h.queueExists(r, name)
})
}

func (h *Handler) accountExists(r *http.Request, name string) bool {
if h.accounts == nil || name == storagedriver.AzureDefaultStorageAccount {
return false
}

_, err := h.accounts.GetStorageAccount(r.Context(), name)

return err == nil
}

func (h *Handler) queueExists(r *http.Request, key string) bool {
_, err := h.resolveQueueURL(r, key)
return err == nil
}

// queueKey is the driver name of queue in account: the bare name in the
// default account, "{account}/{queue}" in any other. Queue names cannot hold
// "/", so keys never collide.
func queueKey(account, queue string) string {
return storagedriver.AzureContainerKey(account, queue)
}

// inAccount reports whether the driver queue key belongs to account and, if
// so, the queue's own name.
func inAccount(key, account string) (string, bool) {
acct, name := storagedriver.SplitAzureContainerKey(key)
if acct == storagedriver.AzureDefaultStorageAccount {
acct = ""
}

return name, acct == account
}

// queueSDKProducts are the User-Agent product names of the Azure SDK Queue
// clients, lowercased.
//
//nolint:gochecknoglobals // read-only lookup table, not mutable state
var queueSDKProducts = []string{
"azsdk-go-azqueue",
"azsdk-python-storage-queue",
"azsdk-java-azure-storage-queue",
"azsdk-js-storage-queue",
"azsdk-net-storage.queues",
}

// isQueueClient reports whether a request on a host that does not name its
// service comes from an Azure SDK Queue client. List Queues and the
// account-level service calls have the same shape as their Blob counterparts,
// so on a bare host only a product token of the User-Agent (such as
// "azsdk-go-azqueue/v1.0.0") picks Queue. Free text such as an application id
// is ignored, and anything else goes to Blob. Other Queue clients should use
// the {account}.queue host or the path-style /{account}/ form.
func isQueueClient(r *http.Request) bool {
for _, token := range strings.Fields(strings.ToLower(r.UserAgent())) {
product, _, _ := strings.Cut(token, "/")

if slices.Contains(queueSDKProducts, product) {
return true
}
}

return false
}
Loading
Loading