diff --git a/databricks/databricks.go b/databricks/databricks.go new file mode 100644 index 00000000..7c220138 --- /dev/null +++ b/databricks/databricks.go @@ -0,0 +1,166 @@ +// Package databricks provides a portable analytics-workspace API with +// cross-cutting concerns. It wraps a driver.Databricks with recording, +// metrics, rate limiting, error injection, and latency simulation. +package databricks + +import ( + "context" + "time" + + "github.com/stackshy/cloudemu/databricks/driver" + "github.com/stackshy/cloudemu/inject" + "github.com/stackshy/cloudemu/metrics" + "github.com/stackshy/cloudemu/ratelimit" + "github.com/stackshy/cloudemu/recorder" +) + +// Databricks is the portable workspace type wrapping a driver with +// cross-cutting concerns. +type Databricks struct { + driver driver.Databricks + recorder *recorder.Recorder + metrics *metrics.Collector + limiter *ratelimit.Limiter + injector *inject.Injector + latency time.Duration +} + +// NewDatabricks creates a new portable Databricks wrapping the given driver. +func NewDatabricks(d driver.Databricks, opts ...Option) *Databricks { + b := &Databricks{driver: d} + for _, opt := range opts { + opt(b) + } + + return b +} + +// Option configures a portable Databricks. +type Option func(*Databricks) + +// WithRecorder sets the recorder. +func WithRecorder(r *recorder.Recorder) Option { return func(b *Databricks) { b.recorder = r } } + +// WithMetrics sets the metrics collector. +func WithMetrics(m *metrics.Collector) Option { return func(b *Databricks) { b.metrics = m } } + +// WithRateLimiter sets the rate limiter. +func WithRateLimiter(l *ratelimit.Limiter) Option { return func(b *Databricks) { b.limiter = l } } + +// WithErrorInjection sets the error injector. +func WithErrorInjection(i *inject.Injector) Option { return func(b *Databricks) { b.injector = i } } + +// WithLatency sets simulated latency. +func WithLatency(d time.Duration) Option { return func(b *Databricks) { b.latency = d } } + +func (b *Databricks) do(_ context.Context, op string, input any, fn func() (any, error)) (any, error) { + start := time.Now() + + if b.injector != nil { + if err := b.injector.Check("databricks", op); err != nil { + b.rec(op, input, nil, err, time.Since(start)) + return nil, err + } + } + + if b.limiter != nil { + if err := b.limiter.Allow(); err != nil { + b.rec(op, input, nil, err, time.Since(start)) + return nil, err + } + } + + if b.latency > 0 { + time.Sleep(b.latency) + } + + out, err := fn() + dur := time.Since(start) + + if b.metrics != nil { + labels := map[string]string{"service": "databricks", "operation": op} + b.metrics.Counter("calls_total", 1, labels) + b.metrics.Histogram("call_duration", dur, labels) + + if err != nil { + b.metrics.Counter("errors_total", 1, labels) + } + } + + b.rec(op, input, out, err, dur) + + return out, err +} + +func (b *Databricks) rec(op string, input, output any, err error, dur time.Duration) { + if b.recorder != nil { + b.recorder.Record("databricks", op, input, output, err, dur) + } +} + +// CreateWorkspace creates a new managed workspace. +// +//nolint:gocritic // cfg matches the driver interface signature; copied once on entry. +func (b *Databricks) CreateWorkspace(ctx context.Context, cfg driver.WorkspaceConfig) (*driver.Workspace, error) { + out, err := b.do(ctx, "CreateWorkspace", cfg, func() (any, error) { return b.driver.CreateWorkspace(ctx, cfg) }) + if err != nil { + return nil, err + } + + return out.(*driver.Workspace), nil +} + +// GetWorkspace retrieves a workspace by resource group and name. +func (b *Databricks) GetWorkspace(ctx context.Context, resourceGroup, name string) (*driver.Workspace, error) { + out, err := b.do(ctx, "GetWorkspace", name, func() (any, error) { return b.driver.GetWorkspace(ctx, resourceGroup, name) }) + if err != nil { + return nil, err + } + + return out.(*driver.Workspace), nil +} + +// DeleteWorkspace deletes a workspace by resource group and name. +func (b *Databricks) DeleteWorkspace(ctx context.Context, resourceGroup, name string) error { + _, err := b.do(ctx, "DeleteWorkspace", name, func() (any, error) { + return nil, b.driver.DeleteWorkspace(ctx, resourceGroup, name) + }) + + return err +} + +// UpdateWorkspaceTags replaces a workspace's tags. +func (b *Databricks) UpdateWorkspaceTags( + ctx context.Context, resourceGroup, name string, tags map[string]string, +) (*driver.Workspace, error) { + out, err := b.do(ctx, "UpdateWorkspaceTags", name, func() (any, error) { + return b.driver.UpdateWorkspaceTags(ctx, resourceGroup, name, tags) + }) + if err != nil { + return nil, err + } + + return out.(*driver.Workspace), nil +} + +// ListWorkspacesByResourceGroup lists workspaces in a resource group. +func (b *Databricks) ListWorkspacesByResourceGroup(ctx context.Context, resourceGroup string) ([]driver.Workspace, error) { + out, err := b.do(ctx, "ListWorkspacesByResourceGroup", resourceGroup, func() (any, error) { + return b.driver.ListWorkspacesByResourceGroup(ctx, resourceGroup) + }) + if err != nil { + return nil, err + } + + return out.([]driver.Workspace), nil +} + +// ListWorkspaces lists all workspaces in the subscription. +func (b *Databricks) ListWorkspaces(ctx context.Context) ([]driver.Workspace, error) { + out, err := b.do(ctx, "ListWorkspaces", nil, func() (any, error) { return b.driver.ListWorkspaces(ctx) }) + if err != nil { + return nil, err + } + + return out.([]driver.Workspace), nil +} diff --git a/databricks/databricks_test.go b/databricks/databricks_test.go new file mode 100644 index 00000000..248d9fc2 --- /dev/null +++ b/databricks/databricks_test.go @@ -0,0 +1,120 @@ +package databricks + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stackshy/cloudemu/config" + "github.com/stackshy/cloudemu/databricks/driver" + "github.com/stackshy/cloudemu/inject" + "github.com/stackshy/cloudemu/metrics" + azuredbx "github.com/stackshy/cloudemu/providers/azure/databricks" + "github.com/stackshy/cloudemu/ratelimit" + "github.com/stackshy/cloudemu/recorder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestDatabricks(opts ...Option) *Databricks { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + o := config.NewOptions(config.WithClock(fc), config.WithRegion("eastus"), config.WithAccountID("sub-1")) + + return NewDatabricks(azuredbx.New(o), opts...) +} + +func validConfig() driver.WorkspaceConfig { + return driver.WorkspaceConfig{ + Name: "ws-1", + ResourceGroup: "rg-1", + Location: "eastus", + ManagedResourceGroupID: "/subscriptions/sub-1/resourceGroups/managed", + } +} + +func TestLifecycle(t *testing.T) { + b := newTestDatabricks() + ctx := context.Background() + + ws, err := b.CreateWorkspace(ctx, validConfig()) + require.NoError(t, err) + assert.Equal(t, driver.StateSucceeded, ws.ProvisioningState) + + got, err := b.GetWorkspace(ctx, "rg-1", "ws-1") + require.NoError(t, err) + assert.Equal(t, "ws-1", got.Name) + + updated, err := b.UpdateWorkspaceTags(ctx, "rg-1", "ws-1", map[string]string{"env": "prod"}) + require.NoError(t, err) + assert.Equal(t, "prod", updated.Tags["env"]) + + byRG, err := b.ListWorkspacesByResourceGroup(ctx, "rg-1") + require.NoError(t, err) + assert.Len(t, byRG, 1) + + all, err := b.ListWorkspaces(ctx) + require.NoError(t, err) + assert.Len(t, all, 1) + + require.NoError(t, b.DeleteWorkspace(ctx, "rg-1", "ws-1")) + + _, err = b.GetWorkspace(ctx, "rg-1", "ws-1") + require.Error(t, err) +} + +func TestWithRecorder(t *testing.T) { + rec := recorder.New() + b := newTestDatabricks(WithRecorder(rec)) + + _, err := b.CreateWorkspace(context.Background(), validConfig()) + require.NoError(t, err) + + calls := rec.Calls() + require.GreaterOrEqual(t, len(calls), 1) + assert.Equal(t, "databricks", calls[0].Service) + assert.Equal(t, "CreateWorkspace", calls[0].Operation) +} + +func TestWithMetrics(t *testing.T) { + mc := metrics.NewCollector() + b := newTestDatabricks(WithMetrics(mc)) + + _, err := b.ListWorkspaces(context.Background()) + require.NoError(t, err) + + q := metrics.NewQuery(mc) + assert.GreaterOrEqual(t, q.ByName("calls_total").Count(), 1) +} + +func TestWithErrorInjection(t *testing.T) { + inj := inject.NewInjector() + b := newTestDatabricks(WithErrorInjection(inj)) + + inj.Set("databricks", "ListWorkspaces", fmt.Errorf("injected failure"), inject.Always{}) + + _, err := b.ListWorkspaces(context.Background()) + require.Error(t, err) +} + +func TestWithLatency(t *testing.T) { + b := newTestDatabricks(WithLatency(time.Millisecond)) + + start := time.Now() + _, err := b.ListWorkspaces(context.Background()) + require.NoError(t, err) + assert.GreaterOrEqual(t, time.Since(start), time.Millisecond) +} + +func TestWithRateLimiter(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + o := config.NewOptions(config.WithClock(fc), config.WithRegion("eastus")) + lim := ratelimit.New(1, 1, fc) + b := NewDatabricks(azuredbx.New(o), WithRateLimiter(lim)) + + _, err := b.ListWorkspaces(context.Background()) + require.NoError(t, err) + + _, err = b.ListWorkspaces(context.Background()) + require.Error(t, err) +} diff --git a/databricks/driver/driver.go b/databricks/driver/driver.go new file mode 100644 index 00000000..17b0bfe9 --- /dev/null +++ b/databricks/driver/driver.go @@ -0,0 +1,51 @@ +// Package driver defines the interface for Databricks-style analytics +// workspace services: lifecycle management of managed workspaces. +package driver + +import "context" + +// Provisioning state values for a workspace. +const ( + StateSucceeded = "Succeeded" + StateCreating = "Creating" + StateDeleting = "Deleting" + StateFailed = "Failed" +) + +// WorkspaceConfig describes a workspace to create. +type WorkspaceConfig struct { + Name string + ResourceGroup string + Location string + SKUName string + SKUTier string + ManagedResourceGroupID string + Tags map[string]string +} + +// Workspace describes a managed analytics workspace. +type Workspace struct { + ID string + Name string + ResourceGroup string + Location string + SKUName string + SKUTier string + ManagedResourceGroupID string + WorkspaceURL string + WorkspaceID string + ProvisioningState string + Tags map[string]string + CreatedAt string +} + +// Databricks is the interface that workspace service implementations must +// satisfy. +type Databricks interface { + CreateWorkspace(ctx context.Context, cfg WorkspaceConfig) (*Workspace, error) + GetWorkspace(ctx context.Context, resourceGroup, name string) (*Workspace, error) + DeleteWorkspace(ctx context.Context, resourceGroup, name string) error + UpdateWorkspaceTags(ctx context.Context, resourceGroup, name string, tags map[string]string) (*Workspace, error) + ListWorkspacesByResourceGroup(ctx context.Context, resourceGroup string) ([]Workspace, error) + ListWorkspaces(ctx context.Context) ([]Workspace, error) +} diff --git a/go.mod b/go.mod index 0847ee32..42e72de8 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,10 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.4.2 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v3 v3.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5 v5.7.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.6.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers v1.1.0 @@ -27,6 +29,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.1 github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.1 github.com/aws/aws-sdk-go-v2/service/eks v1.83.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1 github.com/aws/aws-sdk-go-v2/service/neptune v1.44.5 github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 @@ -54,7 +57,6 @@ require ( cloud.google.com/go/monitoring v1.27.0 // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect @@ -64,7 +66,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.21 // indirect diff --git a/go.sum b/go.sum index c24e65d9..8a69b3aa 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontai github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v5 v5.0.0/go.mod h1:HcZY0PHPo/7d75p99lB6lK0qYOP4vLRJUBpiehYXtLQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.6.0 h1:xkWEcbsnJWid3rOf/S/LOHy1I55JA+4kw/f8Tnm+Onc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.6.0/go.mod h1:OWKfCmX4X3Vp2w7GSx1LZn8566tOHJBA6K0IAUVNYx0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks v1.1.0 h1:rQyNHB/4ntzvm5F9WAiaAl7jWII+jaI4rL6sSWxTNeM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks v1.1.0/go.mod h1:4jtknLqzaPtwIz8Y9NBp2rXxeA7BbSICWBD0FDzG2VM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= @@ -68,9 +70,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= @@ -190,9 +191,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= diff --git a/providers/azure/azure.go b/providers/azure/azure.go index 8541048b..da3fa209 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -13,6 +13,7 @@ import ( "github.com/stackshy/cloudemu/providers/azure/azuresql" "github.com/stackshy/cloudemu/providers/azure/blobstorage" "github.com/stackshy/cloudemu/providers/azure/cosmosdb" + "github.com/stackshy/cloudemu/providers/azure/databricks" "github.com/stackshy/cloudemu/providers/azure/eventgrid" "github.com/stackshy/cloudemu/providers/azure/functions" "github.com/stackshy/cloudemu/providers/azure/keyvault" @@ -48,6 +49,7 @@ type Provider struct { PostgresFlex *postgresflex.Mock MySQLFlex *mysqlflex.Mock AKS *aks.Mock + Databricks *databricks.Mock ResourceDiscovery *resourcediscovery.Engine } @@ -76,6 +78,7 @@ func New(opts ...config.Option) *Provider { PostgresFlex: postgresflex.New(o), MySQLFlex: mysqlflex.New(o), AKS: aks.New(o), + Databricks: databricks.New(o), } p.VirtualMachines.SetMonitoring(p.Monitor) p.BlobStorage.SetMonitoring(p.Monitor) diff --git a/providers/azure/databricks/databricks.go b/providers/azure/databricks/databricks.go new file mode 100644 index 00000000..38338379 --- /dev/null +++ b/providers/azure/databricks/databricks.go @@ -0,0 +1,204 @@ +// Package databricks provides an in-memory mock implementation of Azure +// Databricks workspace management (Microsoft.Databricks/workspaces). +package databricks + +import ( + "context" + "fmt" + "hash/fnv" + "time" + + "github.com/stackshy/cloudemu/config" + "github.com/stackshy/cloudemu/databricks/driver" + "github.com/stackshy/cloudemu/errors" + "github.com/stackshy/cloudemu/internal/idgen" + "github.com/stackshy/cloudemu/internal/memstore" +) + +// Compile-time check that Mock implements driver.Databricks. +var _ driver.Databricks = (*Mock)(nil) + +const ( + providerNamespace = "Microsoft.Databricks" + resourceType = "workspaces" + defaultSKU = "standard" + + // urlShardModulo bounds the synthetic regional shard in a workspace URL + // (adb-{id}.{shard}.azuredatabricks.net), matching Azure's 1–2 digit shard. + urlShardModulo = 100 +) + +// Mock is an in-memory mock implementation of the Azure Databricks service. +type Mock struct { + workspaces *memstore.Store[*driver.Workspace] + opts *config.Options +} + +// New creates a new Databricks mock with the given configuration options. +func New(opts *config.Options) *Mock { + return &Mock{ + workspaces: memstore.New[*driver.Workspace](), + opts: opts, + } +} + +// key uniquely identifies a workspace within the subscription: workspace names +// are unique per resource group. +func key(resourceGroup, name string) string { + return resourceGroup + "/" + name +} + +// CreateWorkspace creates a workspace, completing provisioning synchronously. +// +//nolint:gocritic // cfg matches the driver interface signature; copied once on entry. +func (m *Mock) CreateWorkspace(_ context.Context, cfg driver.WorkspaceConfig) (*driver.Workspace, error) { + switch { + case cfg.Name == "": + return nil, errors.New(errors.InvalidArgument, "workspace name is required") + case cfg.ResourceGroup == "": + return nil, errors.New(errors.InvalidArgument, "resource group is required") + case cfg.Location == "": + return nil, errors.New(errors.InvalidArgument, "location is required") + case cfg.ManagedResourceGroupID == "": + return nil, errors.New(errors.InvalidArgument, "managedResourceGroupId is required") + } + + k := key(cfg.ResourceGroup, cfg.Name) + + if existing, ok := m.workspaces.Get(k); ok { + // ARM PUT is create-or-update: apply the mutable fields (tags, SKU) to + // a copy and swap it in, preserving the identity fields (ID, workspace + // ID/URL, created time). Location and managed RG are immutable in real + // Azure, so they are left untouched. + updated := *existing + updated.Tags = copyMap(cfg.Tags) + updated.SKUName = skuOrDefault(cfg.SKUName) + updated.SKUTier = cfg.SKUTier + m.workspaces.Set(k, &updated) + + return cloneWorkspace(&updated), nil + } + + wsID := workspaceID(k) + ws := &driver.Workspace{ + ID: idgen.AzureID(m.opts.AccountID, cfg.ResourceGroup, providerNamespace, resourceType, cfg.Name), + Name: cfg.Name, + ResourceGroup: cfg.ResourceGroup, + Location: cfg.Location, + SKUName: skuOrDefault(cfg.SKUName), + SKUTier: cfg.SKUTier, + ManagedResourceGroupID: cfg.ManagedResourceGroupID, + WorkspaceID: wsID, + WorkspaceURL: fmt.Sprintf("adb-%s.%d.azuredatabricks.net", wsID, hash(k)%urlShardModulo), + ProvisioningState: driver.StateSucceeded, + Tags: copyMap(cfg.Tags), + CreatedAt: m.opts.Clock.Now().UTC().Format(time.RFC3339), + } + + m.workspaces.Set(k, ws) + + return cloneWorkspace(ws), nil +} + +// GetWorkspace returns a workspace by resource group and name. +func (m *Mock) GetWorkspace(_ context.Context, resourceGroup, name string) (*driver.Workspace, error) { + ws, ok := m.workspaces.Get(key(resourceGroup, name)) + if !ok { + return nil, errors.Newf(errors.NotFound, "workspace %q not found", name) + } + + return cloneWorkspace(ws), nil +} + +// DeleteWorkspace deletes a workspace by resource group and name. +func (m *Mock) DeleteWorkspace(_ context.Context, resourceGroup, name string) error { + if !m.workspaces.Delete(key(resourceGroup, name)) { + return errors.Newf(errors.NotFound, "workspace %q not found", name) + } + + return nil +} + +// UpdateWorkspaceTags replaces a workspace's tags. +func (m *Mock) UpdateWorkspaceTags(_ context.Context, resourceGroup, name string, tags map[string]string) (*driver.Workspace, error) { + k := key(resourceGroup, name) + + ws, ok := m.workspaces.Get(k) + if !ok { + return nil, errors.Newf(errors.NotFound, "workspace %q not found", name) + } + + // Mutate a copy and swap it in rather than writing the shared struct in + // place, so concurrent readers never observe a torn update. + updated := *ws + updated.Tags = copyMap(tags) + m.workspaces.Set(k, &updated) + + return cloneWorkspace(&updated), nil +} + +// ListWorkspacesByResourceGroup lists workspaces in a resource group. +func (m *Mock) ListWorkspacesByResourceGroup(_ context.Context, resourceGroup string) ([]driver.Workspace, error) { + out := make([]driver.Workspace, 0) + + for _, ws := range m.workspaces.All() { + if ws.ResourceGroup == resourceGroup { + out = append(out, *cloneWorkspace(ws)) + } + } + + return out, nil +} + +// ListWorkspaces lists all workspaces in the subscription. +func (m *Mock) ListWorkspaces(_ context.Context) ([]driver.Workspace, error) { + all := m.workspaces.All() + out := make([]driver.Workspace, 0, len(all)) + + for _, ws := range all { + out = append(out, *cloneWorkspace(ws)) + } + + return out, nil +} + +func skuOrDefault(name string) string { + if name == "" { + return defaultSKU + } + + return name +} + +func cloneWorkspace(ws *driver.Workspace) *driver.Workspace { + clone := *ws + clone.Tags = copyMap(ws.Tags) + + return &clone +} + +func copyMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + + return out +} + +// hash returns a deterministic 32-bit FNV hash of s. +func hash(s string) uint32 { + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + + return h.Sum32() +} + +// workspaceID derives a deterministic numeric workspace ID from the key. +func workspaceID(k string) string { + return fmt.Sprintf("%d", uint64(hash(k))*uint64(hash(k+"."))) +} diff --git a/providers/azure/databricks/databricks_test.go b/providers/azure/databricks/databricks_test.go new file mode 100644 index 00000000..02935096 --- /dev/null +++ b/providers/azure/databricks/databricks_test.go @@ -0,0 +1,211 @@ +package databricks + +import ( + "context" + "testing" + "time" + + "github.com/stackshy/cloudemu/config" + "github.com/stackshy/cloudemu/databricks/driver" +) + +func newTestMock() *Mock { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("eastus"), config.WithAccountID("sub-1")) + + return New(opts) +} + +func validConfig() driver.WorkspaceConfig { + return driver.WorkspaceConfig{ + Name: "ws-1", + ResourceGroup: "rg-1", + Location: "eastus", + ManagedResourceGroupID: "/subscriptions/sub-1/resourceGroups/managed", + } +} + +func TestCreateWorkspace(t *testing.T) { + tests := []struct { + name string + mutate func(*driver.WorkspaceConfig) + expectErr bool + }{ + {name: "success", mutate: func(*driver.WorkspaceConfig) {}}, + {name: "missing name", mutate: func(c *driver.WorkspaceConfig) { c.Name = "" }, expectErr: true}, + {name: "missing resource group", mutate: func(c *driver.WorkspaceConfig) { c.ResourceGroup = "" }, expectErr: true}, + {name: "missing location", mutate: func(c *driver.WorkspaceConfig) { c.Location = "" }, expectErr: true}, + {name: "missing managed rg", mutate: func(c *driver.WorkspaceConfig) { c.ManagedResourceGroupID = "" }, expectErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newTestMock() + cfg := validConfig() + tc.mutate(&cfg) + + ws, err := m.CreateWorkspace(context.Background(), cfg) + assertError(t, err, tc.expectErr) + + if tc.expectErr { + return + } + + assertEqual(t, driver.StateSucceeded, ws.ProvisioningState) + assertEqual(t, "standard", ws.SKUName) + assertNotEmpty(t, ws.ID) + assertNotEmpty(t, ws.WorkspaceURL) + assertNotEmpty(t, ws.WorkspaceID) + }) + } +} + +func TestCreateWorkspaceIdempotent(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + first, err := m.CreateWorkspace(ctx, validConfig()) + requireNoError(t, err) + + second, err := m.CreateWorkspace(ctx, validConfig()) + requireNoError(t, err) + + assertEqual(t, first.ID, second.ID) + assertEqual(t, first.WorkspaceID, second.WorkspaceID) +} + +func TestCreateOrUpdateAppliesMutableFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + first, err := m.CreateWorkspace(ctx, validConfig()) + requireNoError(t, err) + + // PUT again with changed tags + SKU — ARM create-or-update must apply them. + cfg := validConfig() + cfg.SKUName = "premium" + cfg.Tags = map[string]string{"env": "prod"} + + updated, err := m.CreateWorkspace(ctx, cfg) + requireNoError(t, err) + + assertEqual(t, "premium", updated.SKUName) + assertEqual(t, "prod", updated.Tags["env"]) + + // Identity fields are preserved across the update. + assertEqual(t, first.ID, updated.ID) + assertEqual(t, first.WorkspaceID, updated.WorkspaceID) + assertEqual(t, first.CreatedAt, updated.CreatedAt) + + // The change is durable. + got, err := m.GetWorkspace(ctx, "rg-1", "ws-1") + requireNoError(t, err) + assertEqual(t, "premium", got.SKUName) + assertEqual(t, "prod", got.Tags["env"]) +} + +func TestGetAndDeleteWorkspace(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateWorkspace(ctx, validConfig()) + requireNoError(t, err) + + got, err := m.GetWorkspace(ctx, "rg-1", "ws-1") + requireNoError(t, err) + assertEqual(t, "ws-1", got.Name) + + _, err = m.GetWorkspace(ctx, "rg-1", "missing") + assertError(t, err, true) + + requireNoError(t, m.DeleteWorkspace(ctx, "rg-1", "ws-1")) + assertError(t, m.DeleteWorkspace(ctx, "rg-1", "ws-1"), true) +} + +func TestUpdateWorkspaceTags(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateWorkspace(ctx, validConfig()) + requireNoError(t, err) + + ws, err := m.UpdateWorkspaceTags(ctx, "rg-1", "ws-1", map[string]string{"env": "prod"}) + requireNoError(t, err) + assertEqual(t, "prod", ws.Tags["env"]) + + _, err = m.UpdateWorkspaceTags(ctx, "rg-1", "missing", nil) + assertError(t, err, true) +} + +func TestListWorkspaces(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + mk := func(name, rg string) { + cfg := validConfig() + cfg.Name = name + cfg.ResourceGroup = rg + _, err := m.CreateWorkspace(ctx, cfg) + requireNoError(t, err) + } + + mk("a", "rg-1") + mk("b", "rg-1") + mk("c", "rg-2") + + rg1, err := m.ListWorkspacesByResourceGroup(ctx, "rg-1") + requireNoError(t, err) + assertEqual(t, 2, len(rg1)) + + all, err := m.ListWorkspaces(ctx) + requireNoError(t, err) + assertEqual(t, 3, len(all)) +} + +func TestTagsCopiedOnCreate(t *testing.T) { + m := newTestMock() + cfg := validConfig() + cfg.Tags = map[string]string{"k": "original"} + + ws, err := m.CreateWorkspace(context.Background(), cfg) + requireNoError(t, err) + + cfg.Tags["k"] = "mutated" + + assertEqual(t, "original", ws.Tags["k"]) +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func assertError(t *testing.T, err error, expectErr bool) { + t.Helper() + + switch { + case expectErr && err == nil: + t.Fatal("expected error, got nil") + case !expectErr && err != nil: + t.Fatalf("unexpected error: %v", err) + } +} + +func assertEqual(t *testing.T, expected, actual any) { + t.Helper() + + if expected != actual { + t.Errorf("expected %v, got %v", expected, actual) + } +} + +func assertNotEmpty(t *testing.T, s string) { + t.Helper() + + if s == "" { + t.Error("expected non-empty string") + } +} diff --git a/server/azure/azure.go b/server/azure/azure.go index 480584d3..9710d5c8 100644 --- a/server/azure/azure.go +++ b/server/azure/azure.go @@ -9,6 +9,7 @@ package azure import ( computedriver "github.com/stackshy/cloudemu/compute/driver" dbdriver "github.com/stackshy/cloudemu/database/driver" + dbxdriver "github.com/stackshy/cloudemu/databricks/driver" iamdriver "github.com/stackshy/cloudemu/iam/driver" "github.com/stackshy/cloudemu/kubernetes" mqdriver "github.com/stackshy/cloudemu/messagequeue/driver" @@ -21,6 +22,7 @@ import ( "github.com/stackshy/cloudemu/server/azure/azuresql" "github.com/stackshy/cloudemu/server/azure/blob" "github.com/stackshy/cloudemu/server/azure/cosmos" + "github.com/stackshy/cloudemu/server/azure/databricks" "github.com/stackshy/cloudemu/server/azure/disks" "github.com/stackshy/cloudemu/server/azure/functions" "github.com/stackshy/cloudemu/server/azure/iam" @@ -62,6 +64,7 @@ type Drivers struct { MySQLFlex rdbdriver.RelationalDB AKS aksserver.Backend IAM iamdriver.IAM + Databricks dbxdriver.Databricks // K8sAPI is the shared in-memory Kubernetes data-plane API server. It is // shared with awsserver.Drivers.K8sAPI and gcpserver.Drivers.K8sAPI so a // kubeconfig issued by any provider's control plane (EKS/AKS/GKE) reaches @@ -153,6 +156,12 @@ func New(d Drivers) *server.Server { srv.Register(aksserver.New(d.AKS)) } + // Databricks matches on Microsoft.Databricks/workspaces — a distinct ARM + // provider name, so registration order is unconstrained. + if d.Databricks != nil { + srv.Register(databricks.New(d.Databricks)) + } + if d.VirtualMachines != nil { srv.Register(virtualmachines.New(d.VirtualMachines)) } diff --git a/server/azure/databricks/handler.go b/server/azure/databricks/handler.go new file mode 100644 index 00000000..68d434cf --- /dev/null +++ b/server/azure/databricks/handler.go @@ -0,0 +1,90 @@ +// Package databricks implements the Azure Databricks (Microsoft.Databricks) +// ARM REST API as a server.Handler. Real +// github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks +// clients configured with a custom endpoint hit this handler the same way they +// hit management.azure.com. +// +// MVP coverage (Microsoft.Databricks/workspaces): +// +// PUT .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Create or update +// GET .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Get +// PATCH .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Update tags +// DELETE .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Delete +// GET .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces — List by resource group +// GET /subscriptions/{sub}/providers/Microsoft.Databricks/workspaces — List by subscription +// +// Mutating ops return 200 OK with the resource body inline so the SDK's LRO +// poller terminates on the first response. +package databricks + +import ( + "net/http" + + dbxdriver "github.com/stackshy/cloudemu/databricks/driver" + "github.com/stackshy/cloudemu/server/wire/azurearm" +) + +const ( + providerName = "Microsoft.Databricks" + resourceType = "workspaces" +) + +// Handler serves Microsoft.Databricks ARM requests against a Databricks driver. +type Handler struct { + dbx dbxdriver.Databricks +} + +// New returns an Azure Databricks handler backed by drv. +func New(drv dbxdriver.Databricks) *Handler { + return &Handler{dbx: drv} +} + +// Matches returns true for ARM Microsoft.Databricks/workspaces paths. +func (*Handler) Matches(r *http.Request) bool { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + return false + } + + return rp.Provider == providerName && rp.ResourceType == resourceType +} + +// ServeHTTP routes the request based on path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") + + return + } + + // Collection: list by resource group (rg present) or by subscription. + if rp.ResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + h.listWorkspaces(w, r, &rp) + + return + } + + switch r.Method { + case http.MethodPut: + h.createOrUpdateWorkspace(w, r, &rp) + case http.MethodGet: + h.getWorkspace(w, r, &rp) + case http.MethodPatch: + h.updateWorkspace(w, r, &rp) + case http.MethodDelete: + h.deleteWorkspace(w, r, &rp) + default: + writeMethodNotAllowed(w) + } +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") +} diff --git a/server/azure/databricks/operations.go b/server/azure/databricks/operations.go new file mode 100644 index 00000000..1db992a4 --- /dev/null +++ b/server/azure/databricks/operations.go @@ -0,0 +1,103 @@ +package databricks + +import ( + "net/http" + + dbxdriver "github.com/stackshy/cloudemu/databricks/driver" + "github.com/stackshy/cloudemu/server/wire/azurearm" +) + +func (h *Handler) createOrUpdateWorkspace(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armWorkspace + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := dbxdriver.WorkspaceConfig{ + Name: rp.ResourceName, + ResourceGroup: rp.ResourceGroup, + Location: body.Location, + Tags: body.Tags, + } + + if body.SKU != nil { + cfg.SKUName = body.SKU.Name + cfg.SKUTier = body.SKU.Tier + } + + if body.Properties != nil { + cfg.ManagedResourceGroupID = body.Properties.ManagedResourceGroupID + } + + ws, err := h.dbx.CreateWorkspace(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMWorkspace(ws)) +} + +func (h *Handler) getWorkspace(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + ws, err := h.dbx.GetWorkspace(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMWorkspace(ws)) +} + +func (h *Handler) updateWorkspace(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body workspaceUpdate + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + ws, err := h.dbx.UpdateWorkspaceTags(r.Context(), rp.ResourceGroup, rp.ResourceName, body.Tags) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMWorkspace(ws)) +} + +func (h *Handler) deleteWorkspace(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.dbx.DeleteWorkspace(r.Context(), rp.ResourceGroup, rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) listWorkspaces(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + workspaces, err := h.listFor(r, rp) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armWorkspace, 0, len(workspaces)) + for i := range workspaces { + out = append(out, toARMWorkspace(&workspaces[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armList{Value: out}) +} + +// listFor selects the resource-group-scoped or subscription-scoped listing +// based on whether the URL carried a resource group. +func (h *Handler) listFor(r *http.Request, rp *azurearm.ResourcePath) ([]dbxdriver.Workspace, error) { + if rp.ResourceGroup != "" { + return h.dbx.ListWorkspacesByResourceGroup(r.Context(), rp.ResourceGroup) + } + + return h.dbx.ListWorkspaces(r.Context()) +} diff --git a/server/azure/databricks/sdk_roundtrip_test.go b/server/azure/databricks/sdk_roundtrip_test.go new file mode 100644 index 00000000..02c6367f --- /dev/null +++ b/server/azure/databricks/sdk_roundtrip_test.go @@ -0,0 +1,182 @@ +package databricks_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" + + "github.com/stackshy/cloudemu" + azureserver "github.com/stackshy/cloudemu/server/azure" +) + +const ( + testRG = "rg-1" + testWS = "my-workspace" + managed = "/subscriptions/sub-1/resourceGroups/databricks-managed-rg" +) + +type fakeCred struct{} + +func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +func newWorkspacesClient(t *testing.T) *armdatabricks.WorkspacesClient { + t.Helper() + + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{Databricks: cloudP.Databricks}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + myCloud := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: ts.URL, Audience: "https://management.azure.com"}, + }, + } + + opts := &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: myCloud, + Transport: ts.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + } + + client, err := armdatabricks.NewWorkspacesClient("sub-1", fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + return client +} + +func createWorkspace(t *testing.T, client *armdatabricks.WorkspacesClient) armdatabricks.Workspace { + t.Helper() + + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, testRG, testWS, armdatabricks.Workspace{ + Location: to.Ptr("eastus"), + SKU: &armdatabricks.SKU{Name: to.Ptr("premium")}, + Tags: map[string]*string{"env": to.Ptr("test")}, + Properties: &armdatabricks.WorkspaceProperties{ + ManagedResourceGroupID: to.Ptr(managed), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + res, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("PollUntilDone: %v", err) + } + + return res.Workspace +} + +func TestSDKWorkspaceLifecycle(t *testing.T) { + client := newWorkspacesClient(t) + ctx := context.Background() + + ws := createWorkspace(t, client) + + if *ws.Name != testWS { + t.Fatalf("got name %q, want %q", *ws.Name, testWS) + } + + if ws.Properties == nil || *ws.Properties.ProvisioningState != armdatabricks.ProvisioningStateSucceeded { + t.Fatalf("expected Succeeded provisioning state, got %+v", ws.Properties) + } + + if ws.Properties.WorkspaceURL == nil || *ws.Properties.WorkspaceURL == "" { + t.Fatal("expected a workspace URL") + } + + got, err := client.Get(ctx, testRG, testWS, nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if *got.Properties.ManagedResourceGroupID != managed { + t.Fatalf("got managed RG %q, want %q", *got.Properties.ManagedResourceGroupID, managed) + } + + updatePoller, err := client.BeginUpdate(ctx, testRG, testWS, armdatabricks.WorkspaceUpdate{ + Tags: map[string]*string{"env": to.Ptr("prod"), "team": to.Ptr("data")}, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + updated, err := updatePoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("update PollUntilDone: %v", err) + } + + if *updated.Tags["env"] != "prod" { + t.Fatalf("expected updated tag env=prod, got %v", updated.Tags) + } + + delPoller, err := client.BeginDelete(ctx, testRG, testWS, nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err = delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone: %v", err) + } + + if _, err = client.Get(ctx, testRG, testWS, nil); err == nil { + t.Fatal("expected error after delete") + } +} + +func TestSDKListWorkspaces(t *testing.T) { + client := newWorkspacesClient(t) + ctx := context.Background() + + createWorkspace(t, client) + + byRG := client.NewListByResourceGroupPager(testRG, nil) + + page, err := byRG.NextPage(ctx) + if err != nil { + t.Fatalf("ListByResourceGroup: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d workspaces in RG, want 1", len(page.Value)) + } + + bySub := client.NewListBySubscriptionPager(nil) + + subPage, err := bySub.NextPage(ctx) + if err != nil { + t.Fatalf("ListBySubscription: %v", err) + } + + if len(subPage.Value) != 1 { + t.Fatalf("got %d workspaces in subscription, want 1", len(subPage.Value)) + } +} + +func TestSDKGetWorkspaceNotFound(t *testing.T) { + client := newWorkspacesClient(t) + + _, err := client.Get(context.Background(), testRG, "does-not-exist", nil) + if err == nil { + t.Fatal("expected error for missing workspace") + } +} diff --git a/server/azure/databricks/types.go b/server/azure/databricks/types.go new file mode 100644 index 00000000..abc5a3de --- /dev/null +++ b/server/azure/databricks/types.go @@ -0,0 +1,64 @@ +package databricks + +import dbxdriver "github.com/stackshy/cloudemu/databricks/driver" + +// JSON wire shapes for the Microsoft.Databricks ARM REST API. Field names match +// what the real armdatabricks client emits and expects. + +// armWorkspace is the ARM resource envelope for a Databricks workspace. +type armWorkspace struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + SKU *armSKU `json:"sku,omitempty"` + Properties *workspaceProps `json:"properties,omitempty"` +} + +type armSKU struct { + Name string `json:"name,omitempty"` + Tier string `json:"tier,omitempty"` +} + +type workspaceProps struct { + ManagedResourceGroupID string `json:"managedResourceGroupId,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` + WorkspaceURL string `json:"workspaceUrl,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + CreatedDateTime string `json:"createdDateTime,omitempty"` +} + +// workspaceUpdate is the PATCH body shape (tags-only update). +type workspaceUpdate struct { + Tags map[string]string `json:"tags"` +} + +// armList is the ARM list-response envelope. +type armList struct { + Value []armWorkspace `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +// toARMWorkspace converts a portable Workspace to its ARM JSON shape. +func toARMWorkspace(ws *dbxdriver.Workspace) armWorkspace { + out := armWorkspace{ + ID: ws.ID, + Name: ws.Name, + Type: providerName + "/" + resourceType, + Location: ws.Location, + Tags: ws.Tags, + Properties: &workspaceProps{ + ManagedResourceGroupID: ws.ManagedResourceGroupID, + ProvisioningState: ws.ProvisioningState, + WorkspaceURL: ws.WorkspaceURL, + WorkspaceID: ws.WorkspaceID, + CreatedDateTime: ws.CreatedAt, + }, + } + if ws.SKUName != "" { + out.SKU = &armSKU{Name: ws.SKUName, Tier: ws.SKUTier} + } + + return out +}