diff --git a/docs/features.md b/docs/features.md index 02b5e669..0e692d74 100644 --- a/docs/features.md +++ b/docs/features.md @@ -390,7 +390,7 @@ all, _ := aws.ResourceDiscovery.ListAll(ctx) // for every bucket, instance, VPC, subnet, security group, table, and function ``` -The same field exists on Azure and GCP providers (`azure.ResourceDiscovery`, `gcp.ResourceDiscovery`). Internally, the engine reads from the existing Compute, Networking, Storage, Database, Serverless, Databricks, Kubernetes, and Relational Database drivers — any field that's nil is silently skipped, so partial test wirings work. +The same field exists on Azure and GCP providers (`azure.ResourceDiscovery`, `gcp.ResourceDiscovery`). Internally, the engine reads from the Compute, Networking, Storage, Database, Serverless, Databricks, Kubernetes, and Relational Database drivers — any field that's nil is silently skipped, so partial test wirings work. Managed relational servers (AWS RDS, Azure SQL, the MySQL/PostgreSQL Flexible Servers, and Cloud SQL) surface through their cloud's inventory type strings. ### Engine API diff --git a/docs/services.md b/docs/services.md index 7db935d3..5b47414b 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1236,12 +1236,53 @@ A proxy has a single implicit `default` target group; targets are RDS instances - `Metadata`: `DescribeDBEngineVersions`, `DescribeOrderableDBInstanceOptions` — representative per-engine catalogs (2). - `Tagging`: `AddTagsToResource`, `RemoveTagsFromResource`, `ListTagsForResource` — addressed by resource ARN over the tag-bearing stores (instances, clusters, instance/cluster snapshots) (3). -**Total: 21 core operations + 58 optional across 12 type-asserted capability -interfaces (`SubnetGroups`, `ParameterGroups`, `OptionGroups`, `ReadReplicas`, -`AdvancedRestore`, `DBProxies`, `EventSubscriptions`, `ClusterEndpoints`, -`ClusterFailover`, `GlobalClusters`, `Metadata`, `Tagging`).** AWS RDS wires all -of them; other clouds implement the subset that maps to a real resource and -answer `InvalidAction` otherwise. +### Azure & GCP managed-SQL native sub-resources (optional capabilities) + +Each managed-SQL service also exposes its cloud's own child resources and +actions. Like the RDS capabilities above, these are kept out of the core +`RelationalDB` interface and discovered by type assertion, so a driver only +answers for the resources its cloud actually has; others return `InvalidAction`. +The server handlers reach them the way real SDK clients do (ARM sub-resource +routes for Azure, sqladmin sub-collections for Cloud SQL), and the mocks +cascade-delete children when their parent server/instance is deleted. + +| Capability | Operations | Implemented by | +|-----------|-----------|----------------| +| `Databases` | Create / Get / List / Delete | `mysqlflex`, `postgresflex`, `cloudsql` | +| `FirewallRules` | Create / Get / List / Delete | `mysqlflex`, `postgresflex`, `azuresql` | +| `Configurations` | Set / Get / List (server parameters) | `mysqlflex`, `postgresflex` | +| `Failover` | `FailoverInstance` | `mysqlflex`, `cloudsql` | +| `VNetRules` | Create / Get / List / Delete | `azuresql` | +| `ElasticPools` | Create / Get / List / Delete | `azuresql` | +| `FailoverGroups` | Create / Get / List / Delete / Failover | `azuresql` | +| `AADAdmins` | Set / Get / List / Delete | `azuresql` | +| `Users` | Create / Get / List / Update / Delete | `cloudsql` | +| `SslCerts` | Create / Get / List / Delete | `cloudsql` | +| `Clonable` | `CloneInstance` | `cloudsql` | +| `ReplicaPromotion` | `PromoteReplica` | `cloudsql` | +| `ManagedInstances` | managed-instance CRUD + Start/Stop/Failover, managed-database CRUD/List | `azuresql` | + +Cloud SQL also serves the `startReplica`/`stopReplica` instance actions (mapped +onto Start/Stop) and the static `tiers` (`/v1/projects/{p}/tiers`) and `flags` +(`/v1/flags`) reference catalogs. Azure SQL adds the SQL Managed Instance family +(`Microsoft.Sql/managedInstances` + managed databases) alongside the +single-database logical server. Managed relational servers surface in +cross-service discovery (Azure Resource Graph as `microsoft.sql/servers`, +`microsoft.dbformysql/flexibleservers`, +`microsoft.dbforpostgresql/flexibleservers`; GCP Cloud Asset as +`sqladmin.googleapis.com/Instance`), are billed per instance-hour via the +`relationaldb:*` cost catalog, and emit their cloud's monitoring metrics +(including the `Microsoft.Sql/servers/elasticpools` pool namespace). + +**Total: 21 core operations + 109 optional across 25 type-asserted capability +interfaces** — the 12 RDS-oriented ones (`SubnetGroups`, `ParameterGroups`, +`OptionGroups`, `ReadReplicas`, `AdvancedRestore`, `DBProxies`, +`EventSubscriptions`, `ClusterEndpoints`, `ClusterFailover`, `GlobalClusters`, +`Metadata`, `Tagging`) plus the 13 Azure/GCP managed-SQL ones (`Databases`, +`FirewallRules`, `Configurations`, `Failover`, `VNetRules`, `ElasticPools`, +`FailoverGroups`, `AADAdmins`, `Users`, `SslCerts`, `Clonable`, +`ReplicaPromotion`, `ManagedInstances`). Each cloud implements the subset that +maps to a real resource and answers `InvalidAction` otherwise. --- @@ -1823,7 +1864,7 @@ still sees success. | Notification | 8 | | Container Registry | 14 | | Event Bus | 15 | -| Relational Database | 21 (+58 optional) | +| Relational Database | 21 (+109 optional) | | Kubernetes — AWS EKS (control plane) | 21 | | Kubernetes — Azure AKS (control plane) | 18 | | Kubernetes — GCP GKE (control plane) | 26 | @@ -1837,7 +1878,7 @@ still sees success. | Machine Learning — GCP Vertex AI (Go API/driver) | 128 | | AI Search — Azure AI Search (control + data plane) | 53 | | Container Orchestration — AWS ECS | 37 | -| **Grand Total** | **1084** (+67 optional) | +| **Grand Total** | **1084** (+116 optional) | Optional operations are capabilities a driver may implement but is not required to; see the sections marked "optional capability". They are counted separately diff --git a/providers/azure/azure.go b/providers/azure/azure.go index f05fe446..f1af9924 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -29,6 +29,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/azure/tablestorage" "github.com/stackshy/cloudemu/v2/providers/azure/virtualmachines" "github.com/stackshy/cloudemu/v2/providers/azure/vnet" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" "github.com/stackshy/cloudemu/v2/services/resourcediscovery" ) @@ -153,15 +154,84 @@ func New(opts ...config.Option) *Provider { p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderAzure, o.AccountID, o.Region, &resourcediscovery.Drivers{ - Compute: p.VirtualMachines, - Networking: p.VNet, - Storage: p.BlobStorage, - Database: p.CosmosDB, - Serverless: p.Functions, - Databricks: p.Databricks, - Kubernetes: aksDiscovery{p.AKS}, + Compute: p.VirtualMachines, + Networking: p.VNet, + Storage: p.BlobStorage, + Database: p.CosmosDB, + Serverless: p.Functions, + Databricks: p.Databricks, + Kubernetes: aksDiscovery{p.AKS}, + RelationalDB: sqlDiscovery{sql: p.SQL, mysql: p.MySQLFlex, pg: p.PostgresFlex}, }, ) return p } + +// sqlDiscovery adapts the Azure relational mocks (SQL logical servers plus +// MySQL/PostgreSQL Flexible Servers) to the resourcediscovery +// RelationalDatabases capability, so they surface in Resource Graph. +type sqlDiscovery struct { + sql *azuresql.Mock + mysql *mysqlflex.Mock + pg *postgresflex.Mock +} + +func (d sqlDiscovery) DiscoverDatabases( + ctx context.Context, +) ([]resourcediscovery.DiscoveredDatabase, error) { + clusters, err := d.sql.DescribeClusters(ctx, nil) + if err != nil { + return nil, err + } + + out := make([]resourcediscovery.DiscoveredDatabase, 0, len(clusters)) + + for i := range clusters { + out = append(out, resourcediscovery.DiscoveredDatabase{ + Name: clusters[i].ID, Type: resourcediscovery.TypeSQLServer, + ARN: clusters[i].ARN, Tags: clusters[i].Tags, + }) + } + + myInsts, err := d.mysql.DescribeInstances(ctx, nil) + if err != nil { + return nil, err + } + + out = appendFlexServers(out, myInsts, resourcediscovery.TypeMySQLFlex) + + pgInsts, err := d.pg.DescribeInstances(ctx, nil) + if err != nil { + return nil, err + } + + out = appendFlexServers(out, pgInsts, resourcediscovery.TypePostgresFlex) + + mis, err := d.sql.ListManagedInstances(ctx) + if err != nil { + return nil, err + } + + for i := range mis { + out = append(out, resourcediscovery.DiscoveredDatabase{ + Name: mis[i].Name, Type: resourcediscovery.TypeManagedInstance, + Region: mis[i].Location, ARN: mis[i].ARN, Tags: mis[i].Tags, + }) + } + + return out, nil +} + +func appendFlexServers( + out []resourcediscovery.DiscoveredDatabase, insts []rdsdriver.Instance, typ string, +) []resourcediscovery.DiscoveredDatabase { + for i := range insts { + out = append(out, resourcediscovery.DiscoveredDatabase{ + Name: insts[i].ID, Type: typ, Region: insts[i].AvailabilityZone, + ARN: insts[i].ARN, Tags: insts[i].Tags, + }) + } + + return out +} diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index dc777799..77fc71be 100644 --- a/providers/azure/azuresql/azuresql.go +++ b/providers/azure/azuresql/azuresql.go @@ -24,6 +24,7 @@ package azuresql import ( "context" "fmt" + "strings" "sync" "github.com/stackshy/cloudemu/v2/config" @@ -58,6 +59,18 @@ type Mock struct { // snapshots key = snapshot id snapshots *memstore.Store[rdsdriver.Snapshot] + // child resources keyed "server/name" + firewallRules *memstore.Store[rdsdriver.FirewallRule] + vnetRules *memstore.Store[rdsdriver.VNetRule] + elasticPools *memstore.Store[rdsdriver.ElasticPool] + failoverGroups *memstore.Store[rdsdriver.FailoverGroup] + // aadAdmins key = server name (a server has at most one) + aadAdmins *memstore.Store[rdsdriver.AADAdmin] + + // managed instances key = instance name; managed databases key = "mi/db" + managedInstances *memstore.Store[rdsdriver.ManagedInstance] + managedDatabases *memstore.Store[rdsdriver.ManagedDatabase] + opts *config.Options monitoring mondriver.Monitoring } @@ -65,10 +78,17 @@ type Mock struct { // New creates a new Azure SQL mock. func New(opts *config.Options) *Mock { return &Mock{ - clusters: memstore.New[rdsdriver.Cluster](), - instances: memstore.New[rdsdriver.Instance](), - snapshots: memstore.New[rdsdriver.Snapshot](), - opts: opts, + clusters: memstore.New[rdsdriver.Cluster](), + instances: memstore.New[rdsdriver.Instance](), + snapshots: memstore.New[rdsdriver.Snapshot](), + firewallRules: memstore.New[rdsdriver.FirewallRule](), + vnetRules: memstore.New[rdsdriver.VNetRule](), + elasticPools: memstore.New[rdsdriver.ElasticPool](), + failoverGroups: memstore.New[rdsdriver.FailoverGroup](), + aadAdmins: memstore.New[rdsdriver.AADAdmin](), + managedInstances: memstore.New[rdsdriver.ManagedInstance](), + managedDatabases: memstore.New[rdsdriver.ManagedDatabase](), + opts: opts, } } @@ -128,6 +148,36 @@ func copyTags(src map[string]string) map[string]string { return out } +// cloneInstance / cloneCluster / cloneSnapshot deep-copy the slice/map fields so +// a returned value never aliases the memstore — a caller mutating its result +// (or a concurrent reader) can't corrupt the store or trigger a concurrent-map +// read/write panic. Callers own the returned copy. +// +//nolint:gocritic // value copy is intentional — the result must not alias the store. +func cloneInstance(inst rdsdriver.Instance) rdsdriver.Instance { + inst.Tags = copyTags(inst.Tags) + inst.VPCSecurityGroups = cloneStrings(inst.VPCSecurityGroups) + inst.ReadReplicaTargets = cloneStrings(inst.ReadReplicaTargets) + + return inst +} + +//nolint:gocritic // value copy is intentional — the result must not alias the store. +func cloneCluster(c rdsdriver.Cluster) rdsdriver.Cluster { + c.Tags = copyTags(c.Tags) + c.VPCSecurityGroups = cloneStrings(c.VPCSecurityGroups) + c.Members = cloneStrings(c.Members) + + return c +} + +//nolint:gocritic // value copy is intentional — the result must not alias the store. +func cloneSnapshot(s rdsdriver.Snapshot) rdsdriver.Snapshot { + s.Tags = copyTags(s.Tags) + + return s +} + // CreateInstance creates a new database under an existing logical server. // //nolint:gocritic // cfg matches the driver interface signature. @@ -155,6 +205,10 @@ func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) ( "database %q already exists on server %q", cfg.ID, cfg.ClusterID) } + if err := m.requireElasticPool(cfg.ClusterID, cfg.ElasticPoolID); err != nil { + return nil, err + } + storage := cfg.AllocatedStorage if storage == 0 { storage = defaultStorageGB @@ -182,6 +236,7 @@ func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) ( Port: defaultPort, State: rdsdriver.StateAvailable, ClusterID: cfg.ClusterID, + ElasticPoolID: cfg.ElasticPoolID, AvailabilityZone: server.SubnetGroupName, // re-use as region carrier CreatedAt: m.opts.Clock.Now().UTC(), Tags: copyTags(cfg.Tags), @@ -194,7 +249,7 @@ func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) ( m.emitDatabaseMetrics(cfg.ClusterID, cfg.ID, cpuMetricRunning, dtuRunning) - out := inst + out := cloneInstance(inst) return &out, nil } @@ -213,7 +268,7 @@ func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.I //nolint:gocritic // map values are large structs; copy is unavoidable when materializing the result slice. for _, v := range all { - out = append(out, v) + out = append(out, cloneInstance(v)) } return out, nil @@ -227,7 +282,7 @@ func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.I return nil, err } - out = append(out, inst) + out = append(out, cloneInstance(inst)) } return out, nil @@ -260,6 +315,8 @@ func (m *Mock) lookupInstance(id string) (rdsdriver.Instance, error) { } // ModifyInstance applies the supplied changes to a database. +// +//nolint:gocritic // input matches the driver interface signature. func (m *Mock) ModifyInstance( _ context.Context, id string, input rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Instance, error) { @@ -283,13 +340,21 @@ func (m *Mock) ModifyInstance( inst.EngineVersion = input.EngineVersion } + if input.ElasticPoolID != "" { + if err := m.requireElasticPool(inst.ClusterID, input.ElasticPoolID); err != nil { + return nil, err + } + + inst.ElasticPoolID = input.ElasticPoolID + } + if input.Tags != nil { inst.Tags = copyTags(input.Tags) } m.instances.Set(instanceKey(inst.ClusterID, inst.ID), inst) - out := inst + out := cloneInstance(inst) return &out, nil } @@ -405,7 +470,7 @@ func (m *Mock) CreateCluster(_ context.Context, cfg rdsdriver.ClusterConfig) (*r m.clusters.Set(cfg.ID, cluster) - out := cluster + out := cloneCluster(cluster) return &out, nil } @@ -421,7 +486,7 @@ func (m *Mock) DescribeClusters(_ context.Context, ids []string) ([]rdsdriver.Cl //nolint:gocritic // map values are large structs; copy is unavoidable when materializing the result slice. for _, v := range all { - out = append(out, v) + out = append(out, cloneCluster(v)) } return out, nil @@ -435,13 +500,15 @@ func (m *Mock) DescribeClusters(_ context.Context, ids []string) ([]rdsdriver.Cl return nil, cerrors.Newf(cerrors.NotFound, "Azure SQL server %q not found", id) } - out = append(out, cluster) + out = append(out, cloneCluster(cluster)) } return out, nil } // ModifyCluster updates server-level fields (admin password reset, version). +// +//nolint:gocritic // input matches the driver interface signature. func (m *Mock) ModifyCluster( _ context.Context, id string, input rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Cluster, error) { @@ -463,7 +530,7 @@ func (m *Mock) ModifyCluster( m.clusters.Set(id, cluster) - out := cluster + out := cloneCluster(cluster) return &out, nil } @@ -484,10 +551,44 @@ func (m *Mock) DeleteCluster(_ context.Context, id string) error { } m.clusters.Delete(id) + m.deleteChildren(id) return nil } +// deleteChildren removes the firewall rules, vnet rules, elastic pools, +// failover groups and AAD admin belonging to server id, matching Azure's +// cascade delete on server removal. The caller already holds the write lock. +func (m *Mock) deleteChildren(server string) { + prefix := server + "/" + + for key := range m.firewallRules.All() { + if strings.HasPrefix(key, prefix) { + m.firewallRules.Delete(key) + } + } + + for key := range m.vnetRules.All() { + if strings.HasPrefix(key, prefix) { + m.vnetRules.Delete(key) + } + } + + for key := range m.elasticPools.All() { + if strings.HasPrefix(key, prefix) { + m.elasticPools.Delete(key) + } + } + + for key := range m.failoverGroups.All() { + if strings.HasPrefix(key, prefix) { + m.failoverGroups.Delete(key) + } + } + + m.aadAdmins.Delete(server) +} + // StartCluster / StopCluster are no-ops on Azure SQL servers. They aren't // "started" / "stopped" the way RDS clusters are; the underlying databases // are independently controlled. @@ -528,7 +629,7 @@ func (m *Mock) CreateSnapshot(_ context.Context, cfg rdsdriver.SnapshotConfig) ( m.snapshots.Set(cfg.ID, snap) - out := snap + out := cloneSnapshot(snap) return &out, nil } @@ -557,7 +658,7 @@ func (m *Mock) DescribeSnapshots( } } - out = append(out, snap) + out = append(out, cloneSnapshot(snap)) } return out, nil @@ -644,7 +745,7 @@ func (m *Mock) RestoreInstanceFromSnapshot( m.emitDatabaseMetrics(server, dbName, cpuMetricRunning, dtuRunning) - out := inst + out := cloneInstance(inst) return &out, nil } diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index ec412754..5dac686c 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -2,10 +2,13 @@ package azuresql import ( "context" + "fmt" + "sync" "testing" "time" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/azure/azuremonitor" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -245,3 +248,647 @@ func assertNotEmpty(t *testing.T, s string) { t.Error("expected non-empty string") } } + +func TestSubResourcesRequireServer(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "ghost", Name: "r"}); err == nil { + t.Error("CreateFirewallRule on missing server: expected error") + } + + if _, err := m.CreateVNetRule(ctx, rdsdriver.VNetRuleConfig{Server: "ghost", Name: "v"}); err == nil { + t.Error("CreateVNetRule on missing server: expected error") + } + + if _, err := m.CreateElasticPool(ctx, rdsdriver.ElasticPoolConfig{Server: "ghost", Name: "p"}); err == nil { + t.Error("CreateElasticPool on missing server: expected error") + } + + if _, err := m.CreateFailoverGroup(ctx, rdsdriver.FailoverGroupConfig{Server: "ghost", Name: "f"}); err == nil { + t.Error("CreateFailoverGroup on missing server: expected error") + } + + if _, err := m.SetAADAdmin(ctx, rdsdriver.AADAdminConfig{Server: "ghost"}); err == nil { + t.Error("SetAADAdmin on missing server: expected error") + } +} + +func TestFailoverGroupWithoutPartnerRejected(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + // A group with no partner server can't fail over — otherwise it would + // ping-pong its role and leave a Secondary with no Primary. + if _, err := m.CreateFailoverGroup(ctx, rdsdriver.FailoverGroupConfig{Server: "srv", Name: "fg"}); err != nil { + t.Fatalf("CreateFailoverGroup: %v", err) + } + + if _, err := m.FailoverFailoverGroup(ctx, "srv", "fg"); err == nil { + t.Error("FailoverFailoverGroup with no partner: expected FailedPrecondition") + } +} + +func TestCreateFirewallRuleRejectsReversedRange(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{ + Server: "srv", Name: "r", StartIPAddress: "10.0.0.9", EndIPAddress: "10.0.0.1", + }); err == nil { + t.Error("CreateFirewallRule with start > end: expected InvalidArgument") + } + + // Equal start/end (single-address rule) is allowed. + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{ + Server: "srv", Name: "single", StartIPAddress: "10.0.0.5", EndIPAddress: "10.0.0.5", + }); err != nil { + t.Errorf("CreateFirewallRule with start == end: %v", err) + } +} + +func TestFailoverGroupRoleFlipAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + fg, err := m.CreateFailoverGroup(ctx, rdsdriver.FailoverGroupConfig{ + Server: "srv", Name: "fg", PartnerServers: []string{"partner"}, Databases: []string{"db1"}, + }) + if err != nil { + t.Fatalf("CreateFailoverGroup: %v", err) + } + + if fg.ReplicationRole != "Primary" { + t.Errorf("initial role: got %q, want Primary", fg.ReplicationRole) + } + + flipped, err := m.FailoverFailoverGroup(ctx, "srv", "fg") + if err != nil { + t.Fatalf("FailoverFailoverGroup: %v", err) + } + + if flipped.ReplicationRole != "Secondary" { + t.Errorf("after failover: got %q, want Secondary", flipped.ReplicationRole) + } + + // Mutating the returned slice must not affect stored state. + flipped.PartnerServers[0] = "tampered" + + reread, _ := m.GetFailoverGroup(ctx, "srv", "fg") + if reread.PartnerServers[0] != "partner" { + t.Error("returned slice aliased stored state") + } + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r", StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.9"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if err := m.DeleteCluster(ctx, "srv"); err != nil { + t.Fatalf("DeleteCluster: %v", err) + } + + if _, err := m.ListFirewallRules(ctx, "srv"); err == nil { + t.Error("ListFirewallRules after server delete: expected server NotFound") + } +} + +func TestElasticPoolEmitsMetrics(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("eastus")) + + m := New(opts) + mon := azuremonitor.New(opts) + m.SetMonitoring(mon) + + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateElasticPool(ctx, rdsdriver.ElasticPoolConfig{Server: "srv", Name: "pool"}); err != nil { + t.Fatalf("CreateElasticPool: %v", err) + } + + names, err := mon.ListMetrics(ctx, "Microsoft.Sql/servers/elasticpools") + if err != nil { + t.Fatalf("ListMetrics: %v", err) + } + + var sawCPU bool + for _, n := range names { + if n == "cpu_percent" { + sawCPU = true + } + } + + if !sawCPU { + t.Fatalf("expected cpu_percent on the elastic-pool namespace, got %v", names) + } +} + +func TestManagedInstanceLifecycleAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi", SubnetID: "/subnets/mi"}); err != nil { + t.Fatalf("CreateManagedInstance: %v", err) + } + + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi"}); err == nil { + t.Error("duplicate managed instance: expected AlreadyExists") + } + + if _, err := m.CreateManagedDatabase(ctx, rdsdriver.ManagedDatabaseConfig{Instance: "ghost", Name: "db"}); err == nil { + t.Error("managed database on missing instance: expected NotFound") + } + + if _, err := m.CreateManagedDatabase(ctx, rdsdriver.ManagedDatabaseConfig{Instance: "mi", Name: "db"}); err != nil { + t.Fatalf("CreateManagedDatabase: %v", err) + } + + if err := m.StopManagedInstance(ctx, "mi"); err != nil { + t.Fatalf("StopManagedInstance: %v", err) + } + + got, _ := m.GetManagedInstance(ctx, "mi") + if got.State != "Stopped" { + t.Errorf("state after stop: got %q, want Stopped", got.State) + } + + // Deleting the instance cascades to its managed databases. + if err := m.DeleteManagedInstance(ctx, "mi"); err != nil { + t.Fatalf("DeleteManagedInstance: %v", err) + } + + if _, err := m.ListManagedDatabases(ctx, "mi"); err == nil { + t.Error("ListManagedDatabases after instance delete: expected NotFound") + } +} + +func TestElasticPoolMembershipBlocksDelete(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateElasticPool(ctx, rdsdriver.ElasticPoolConfig{Server: "srv", Name: "pool"}); err != nil { + t.Fatalf("CreateElasticPool: %v", err) + } + + poolID := "/subscriptions/x/resourceGroups/x/providers/Microsoft.Sql/servers/srv/elasticPools/pool" + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "db1", ClusterID: "srv", ElasticPoolID: poolID, + }); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // The database round-trips its pool membership. + got, _ := m.DescribeInstances(ctx, []string{"srv/db1"}) + if len(got) != 1 || got[0].ElasticPoolID != poolID { + t.Fatalf("elasticPoolId not persisted: %+v", got) + } + + // A non-empty pool cannot be deleted. + if err := m.DeleteElasticPool(ctx, "srv", "pool"); err == nil { + t.Error("DeleteElasticPool on non-empty pool: expected FailedPrecondition") + } + + // After the database is removed, the pool deletes cleanly. + if err := m.DeleteInstance(ctx, "srv/db1"); err != nil { + t.Fatalf("DeleteInstance: %v", err) + } + + if err := m.DeleteElasticPool(ctx, "srv", "pool"); err != nil { + t.Errorf("DeleteElasticPool on empty pool: %v", err) + } +} + +// TestConcurrentSubResourceAccess exercises the mock under -race: concurrent +// mutators and readers, plus a caller mutating the Tags map returned from a +// managed-instance read (which must be a clone, not the stored map). +func TestConcurrentSubResourceAccess(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{ + Name: "mi", SubnetID: "/subnets/mi", Tags: map[string]string{"a": "b"}, + }); err != nil { + t.Fatalf("CreateManagedInstance: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 25; i++ { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + _, _ = m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{ + Server: "srv", Name: fmt.Sprintf("r%d", i), + StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.2", + }) + _, _ = m.ListFirewallRules(ctx, "srv") + + if got, err := m.GetManagedInstance(ctx, "mi"); err == nil { + // Mutating the returned Tags must not race the stored map. + got.Tags["writer"] = "x" + } + }(i) + } + + wg.Wait() +} + +func TestManagedInstanceStateGuards(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi", SubnetID: "/subnets/mi"}); err != nil { + t.Fatalf("CreateManagedInstance: %v", err) + } + + // Stop, then a failover on a stopped instance is rejected (not silently started). + if err := m.StopManagedInstance(ctx, "mi"); err != nil { + t.Fatalf("StopManagedInstance: %v", err) + } + + if err := m.FailoverManagedInstance(ctx, "mi"); err == nil { + t.Error("FailoverManagedInstance on a stopped instance: expected FailedPrecondition") + } + + got, _ := m.GetManagedInstance(ctx, "mi") + if got.State != "Stopped" { + t.Errorf("state after stop: got %q, want Stopped", got.State) + } + + // Idempotent stop; start from stopped; failover once ready. + if err := m.StopManagedInstance(ctx, "mi"); err != nil { + t.Errorf("StopManagedInstance (idempotent): %v", err) + } + + if err := m.StartManagedInstance(ctx, "mi"); err != nil { + t.Fatalf("StartManagedInstance: %v", err) + } + + if err := m.FailoverManagedInstance(ctx, "mi"); err != nil { + t.Errorf("FailoverManagedInstance on a ready instance: %v", err) + } + + if err := m.StartManagedInstance(ctx, "ghost"); err == nil { + t.Error("StartManagedInstance on missing instance: expected NotFound") + } +} + +func TestSubResourceCRUDCoverage(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + // VNet rules: create, get, list, delete. + if _, err := m.CreateVNetRule(ctx, rdsdriver.VNetRuleConfig{Server: "srv", Name: "v1", SubnetID: "/subnets/a"}); err != nil { + t.Fatalf("CreateVNetRule: %v", err) + } + + if got, err := m.GetVNetRule(ctx, "srv", "v1"); err != nil || got.SubnetID != "/subnets/a" { + t.Fatalf("GetVNetRule: %+v %v", got, err) + } + + if vs, err := m.ListVNetRules(ctx, "srv"); err != nil || len(vs) != 1 { + t.Fatalf("ListVNetRules: %d %v", len(vs), err) + } + + if err := m.DeleteVNetRule(ctx, "srv", "v1"); err != nil { + t.Fatalf("DeleteVNetRule: %v", err) + } + + if err := m.DeleteVNetRule(ctx, "srv", "v1"); err == nil { + t.Error("DeleteVNetRule again: expected NotFound") + } + + // Elastic pools: create, get, list, update, delete. + if _, err := m.CreateElasticPool(ctx, rdsdriver.ElasticPoolConfig{Server: "srv", Name: "p1", SKUName: "GP_Gen5", MaxCapacity: 4}); err != nil { + t.Fatalf("CreateElasticPool: %v", err) + } + + if got, err := m.GetElasticPool(ctx, "srv", "p1"); err != nil || got.SKUName != "GP_Gen5" { + t.Fatalf("GetElasticPool: %+v %v", got, err) + } + + if ps, err := m.ListElasticPools(ctx, "srv"); err != nil || len(ps) != 1 { + t.Fatalf("ListElasticPools: %d %v", len(ps), err) + } + + updated, err := m.UpdateElasticPool(ctx, rdsdriver.ElasticPoolConfig{ + Server: "srv", Name: "p1", SKUName: "GP_Gen5_8", SKUTier: "GeneralPurpose", + MaxSizeBytes: 1 << 40, MinCapacity: 1, MaxCapacity: 8, Location: "westus", + }) + if err != nil || updated.MaxCapacity != 8 || updated.SKUName != "GP_Gen5_8" { + t.Fatalf("UpdateElasticPool: %+v %v", updated, err) + } + + if err := m.DeleteElasticPool(ctx, "srv", "p1"); err != nil { + t.Fatalf("DeleteElasticPool: %v", err) + } + + // Failover groups: get, list, update, delete. + if _, err := m.CreateFailoverGroup(ctx, rdsdriver.FailoverGroupConfig{Server: "srv", Name: "fg", PartnerServers: []string{"p"}, Databases: []string{"d1"}}); err != nil { + t.Fatalf("CreateFailoverGroup: %v", err) + } + + if got, err := m.GetFailoverGroup(ctx, "srv", "fg"); err != nil || len(got.Databases) != 1 { + t.Fatalf("GetFailoverGroup: %+v %v", got, err) + } + + if fgs, err := m.ListFailoverGroups(ctx, "srv"); err != nil || len(fgs) != 1 { + t.Fatalf("ListFailoverGroups: %d %v", len(fgs), err) + } + + upd, err := m.UpdateFailoverGroup(ctx, rdsdriver.FailoverGroupConfig{ + Server: "srv", Name: "fg", FailoverPolicy: "Automatic", GracePeriodMinutes: 60, + PartnerServers: []string{"p2"}, Databases: []string{"d1", "d2"}, + }) + if err != nil || len(upd.Databases) != 2 || upd.FailoverPolicy != "Automatic" { + t.Fatalf("UpdateFailoverGroup: %+v %v", upd, err) + } + + if err := m.DeleteFailoverGroup(ctx, "srv", "fg"); err != nil { + t.Fatalf("DeleteFailoverGroup: %v", err) + } + + // AAD admin: set, get, list, delete. + if _, err := m.SetAADAdmin(ctx, rdsdriver.AADAdminConfig{Server: "srv", Login: "admin@contoso.com", SID: "sid-1"}); err != nil { + t.Fatalf("SetAADAdmin: %v", err) + } + + if got, err := m.GetAADAdmin(ctx, "srv", ""); err != nil || got.Login != "admin@contoso.com" { + t.Fatalf("GetAADAdmin: %+v %v", got, err) + } + + if as, err := m.ListAADAdmins(ctx, "srv"); err != nil || len(as) != 1 { + t.Fatalf("ListAADAdmins: %d %v", len(as), err) + } + + if err := m.DeleteAADAdmin(ctx, "srv", ""); err != nil { + t.Fatalf("DeleteAADAdmin: %v", err) + } +} + +func TestModifyInstanceMergesFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "db", ClusterID: "srv", AllocatedStorage: 10}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + out, err := m.ModifyInstance(ctx, "srv/db", rdsdriver.ModifyInstanceInput{AllocatedStorage: 50, EngineVersion: "12.0"}) + if err != nil { + t.Fatalf("ModifyInstance: %v", err) + } + + if out.AllocatedStorage != 50 || out.EngineVersion != "12.0" { + t.Errorf("ModifyInstance merge: got storage=%d version=%q", out.AllocatedStorage, out.EngineVersion) + } + + if _, err := m.ModifyInstance(ctx, "srv/ghost", rdsdriver.ModifyInstanceInput{}); err == nil { + t.Error("ModifyInstance on missing instance: expected NotFound") + } +} + +func TestManagedInstanceAndDatabaseCRUD(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi", SubnetID: "/subnets/mi", VCores: 4, StorageGB: 32}); err != nil { + t.Fatalf("CreateManagedInstance: %v", err) + } + + upd, err := m.UpdateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi", VCores: 8}) + if err != nil || upd.VCores != 8 { + t.Fatalf("UpdateManagedInstance: %+v %v", upd, err) + } + + if mis, err := m.ListManagedInstances(ctx); err != nil || len(mis) != 1 { + t.Fatalf("ListManagedInstances: %d %v", len(mis), err) + } + + // Managed databases. + if _, err := m.CreateManagedDatabase(ctx, rdsdriver.ManagedDatabaseConfig{Instance: "mi", Name: "mdb"}); err != nil { + t.Fatalf("CreateManagedDatabase: %v", err) + } + + if got, err := m.GetManagedDatabase(ctx, "mi", "mdb"); err != nil || got.Name != "mdb" { + t.Fatalf("GetManagedDatabase: %+v %v", got, err) + } + + if dbs, err := m.ListManagedDatabases(ctx, "mi"); err != nil || len(dbs) != 1 { + t.Fatalf("ListManagedDatabases: %d %v", len(dbs), err) + } + + if err := m.DeleteManagedDatabase(ctx, "mi", "mdb"); err != nil { + t.Fatalf("DeleteManagedDatabase: %v", err) + } + + // Deleting the instance cascades to its managed databases. + if _, err := m.CreateManagedDatabase(ctx, rdsdriver.ManagedDatabaseConfig{Instance: "mi", Name: "mdb2"}); err != nil { + t.Fatalf("CreateManagedDatabase 2: %v", err) + } + + if err := m.DeleteManagedInstance(ctx, "mi"); err != nil { + t.Fatalf("DeleteManagedInstance: %v", err) + } + + if _, err := m.GetManagedInstance(ctx, "mi"); err == nil { + t.Error("GetManagedInstance after delete: expected NotFound") + } +} + +func TestSnapshotsAndFirewallCoverage(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "db", ClusterID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + if _, err := m.CreateSnapshot(ctx, rdsdriver.SnapshotConfig{ID: "s1", InstanceID: "srv/db"}); err != nil { + t.Fatalf("CreateSnapshot: %v", err) + } + + // DescribeSnapshots by instance and by id. + if snaps, err := m.DescribeSnapshots(ctx, nil, "srv/db"); err != nil || len(snaps) != 1 { + t.Fatalf("DescribeSnapshots by instance: %d %v", len(snaps), err) + } + + if snaps, err := m.DescribeSnapshots(ctx, []string{"s1"}, ""); err != nil || len(snaps) != 1 { + t.Fatalf("DescribeSnapshots by id: %d %v", len(snaps), err) + } + + // Server-level snapshot ops are unsupported on Azure SQL. + if err := m.DeleteClusterSnapshot(ctx, "x"); err == nil { + t.Error("DeleteClusterSnapshot: expected unsupported") + } + + if _, err := m.RestoreClusterFromSnapshot(ctx, rdsdriver.RestoreClusterInput{}); err == nil { + t.Error("RestoreClusterFromSnapshot: expected unsupported") + } + + // Firewall get + delete. + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r", StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.9"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if got, err := m.GetFirewallRule(ctx, "srv", "r"); err != nil || got.EndIPAddress != "10.0.0.9" { + t.Fatalf("GetFirewallRule: %+v %v", got, err) + } + + if err := m.DeleteFirewallRule(ctx, "srv", "r"); err != nil { + t.Fatalf("DeleteFirewallRule: %v", err) + } + + if err := m.DeleteFirewallRule(ctx, "srv", "r"); err == nil { + t.Error("DeleteFirewallRule again: expected NotFound") + } +} + +func TestDatabaseAndManagedInstanceEmitMetrics(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("eastus")) + + m := New(opts) + mon := azuremonitor.New(opts) + m.SetMonitoring(mon) + + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "db", ClusterID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi", SubnetID: "/subnets/mi"}); err != nil { + t.Fatalf("CreateManagedInstance: %v", err) + } + + if names, err := mon.ListMetrics(ctx, "Microsoft.Sql/servers/databases"); err != nil || len(names) == 0 { + t.Fatalf("database metrics: %v %v", names, err) + } + + if names, err := mon.ListMetrics(ctx, "Microsoft.Sql/managedInstances"); err != nil || len(names) == 0 { + t.Fatalf("managed-instance metrics: %v %v", names, err) + } +} + +func TestDescribeResultsDoNotAliasStore(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv", Tags: map[string]string{"env": "prod"}}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "db", ClusterID: "srv", Tags: map[string]string{"tier": "gold"}, + }); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Mutating returned instance Tags must not corrupt the store. + insts, err := m.DescribeInstances(ctx, []string{"srv/db"}) + requireNoError(t, err) + insts[0].Tags["tier"] = "tampered" + + reread, err := m.DescribeInstances(ctx, []string{"srv/db"}) + requireNoError(t, err) + if reread[0].Tags["tier"] != "gold" { + t.Errorf("instance Tags aliased: got %q, want gold", reread[0].Tags["tier"]) + } + + // Same for clusters. + clusters, err := m.DescribeClusters(ctx, []string{"srv"}) + requireNoError(t, err) + clusters[0].Tags["env"] = "tampered" + clusters[0].Members = append(clusters[0].Members, "phantom") + + rc, err := m.DescribeClusters(ctx, []string{"srv"}) + requireNoError(t, err) + if rc[0].Tags["env"] != "prod" { + t.Errorf("cluster Tags aliased: got %q, want prod", rc[0].Tags["env"]) + } + if len(rc[0].Members) != 1 { + t.Errorf("cluster Members aliased: got %v", rc[0].Members) + } +} + +func TestCreateDatabaseValidatesElasticPool(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, rdsdriver.ClusterConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + // Referencing a nonexistent pool is rejected. + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "db1", ClusterID: "srv", ElasticPoolID: "ghost-pool", + }); err == nil { + t.Error("CreateInstance into a nonexistent pool: expected NotFound") + } + + if _, err := m.CreateElasticPool(ctx, rdsdriver.ElasticPoolConfig{Server: "srv", Name: "pool1"}); err != nil { + t.Fatalf("CreateElasticPool: %v", err) + } + + // Bare name resolves. + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "db1", ClusterID: "srv", ElasticPoolID: "pool1", + }); err != nil { + t.Fatalf("CreateInstance into an existing pool: %v", err) + } + + // Full ARM ID resolves too. + poolID := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Sql/servers/srv/elasticPools/pool1" + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "db2", ClusterID: "srv", ElasticPoolID: poolID, + }); err != nil { + t.Fatalf("CreateInstance with pool ARM ID: %v", err) + } + + // Moving a DB into a nonexistent pool via Modify is rejected. + if _, err := m.ModifyInstance(ctx, "srv/db1", rdsdriver.ModifyInstanceInput{ElasticPoolID: "ghost"}); err == nil { + t.Error("ModifyInstance into a nonexistent pool: expected NotFound") + } +} diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go new file mode 100644 index 00000000..369d6f5e --- /dev/null +++ b/providers/azure/azuresql/managedinstance.go @@ -0,0 +1,350 @@ +package azuresql + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// Azure SQL Managed Instance is a distinct Microsoft.Sql resource type from the +// single-database logical server; it hosts managed databases. Modeled as an +// optional relationaldb capability discovered by type assertion. +var _ rdsdriver.ManagedInstances = (*Mock)(nil) + +const ( + miDefaultSKU = "GP_Gen5" + miDefaultTier = "GeneralPurpose" + miDefaultVCores = 4 + miDefaultStorage = 32 + + miStateReady = "Ready" + miStateStopped = "Stopped" +) + +func (m *Mock) miARN(name string) string { + return idgen.AzureID(m.opts.Region, m.opts.Region, armProvider, "managedInstances", name) +} + +func (m *Mock) mdbARN(instance, name string) string { + return idgen.AzureID(m.opts.Region, m.opts.Region, armProvider, "managedInstances/"+instance+"/databases", name) +} + +// CreateManagedInstance provisions a SQL Managed Instance. +// +//nolint:gocritic // cfg matches the ManagedInstances capability interface signature. +func (m *Mock) CreateManagedInstance( + _ context.Context, cfg rdsdriver.ManagedInstanceConfig, +) (*rdsdriver.ManagedInstance, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "managed instance name is required") + } + + if cfg.SubnetID == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "subnetId is required for a managed instance") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.managedInstances.Get(cfg.Name); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "managed instance %q already exists", cfg.Name) + } + + mi := rdsdriver.ManagedInstance{ + Name: cfg.Name, + Location: orDefault(cfg.Location, m.opts.Region), + AdminLogin: cfg.AdminLogin, + SKUName: orDefault(cfg.SKUName, miDefaultSKU), + SKUTier: orDefault(cfg.SKUTier, miDefaultTier), + LicenseType: orDefault(cfg.LicenseType, "LicenseIncluded"), + SubnetID: cfg.SubnetID, + VCores: orDefaultInt(cfg.VCores, miDefaultVCores), + StorageGB: orDefaultInt(cfg.StorageGB, miDefaultStorage), + State: miStateReady, + FQDN: cfg.Name + ".managed.database.windows.net", + ARN: m.miARN(cfg.Name), + Tags: copyTags(cfg.Tags), + } + + m.managedInstances.Set(cfg.Name, mi) + + m.emitManagedInstanceMetrics(cfg.Name) + + out := mi + out.Tags = copyTags(mi.Tags) + + return &out, nil +} + +// emitManagedInstanceMetrics pushes a representative datapoint set on the +// Microsoft.Sql/managedInstances namespace, matching the instance-scoped +// metrics real Azure Monitor surfaces (siblings emit on create too). +func (m *Mock) emitManagedInstanceMetrics(name string) { + if m.monitoring == nil { + return + } + + const ns = "Microsoft.Sql/managedInstances" + + now := m.opts.Clock.Now() + dims := map[string]string{"resourceId": m.miARN(name)} + + _ = m.monitoring.PutMetricData(context.Background(), []mondriver.MetricDatum{ + {Namespace: ns, MetricName: "avg_cpu_percent", Value: 25, Unit: "Percent", Dimensions: dims, Timestamp: now}, + {Namespace: ns, MetricName: "storage_space_used_mb", Value: 1024, Unit: "Count", Dimensions: dims, Timestamp: now}, + {Namespace: ns, MetricName: "virtual_core_count", Value: 4, Unit: "Count", Dimensions: dims, Timestamp: now}, + }) +} + +// UpdateManagedInstance applies the non-zero fields of cfg to an existing +// managed instance (PATCH merge semantics). +// +//nolint:gocritic // cfg matches the ManagedInstances capability interface signature. +func (m *Mock) UpdateManagedInstance( + _ context.Context, cfg rdsdriver.ManagedInstanceConfig, +) (*rdsdriver.ManagedInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + + mi, ok := m.managedInstances.Get(cfg.Name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "managed instance %q not found", cfg.Name) + } + + // Merge: keep the stored value where the PATCH left the field zero. + mi.Location = orDefault(cfg.Location, mi.Location) + mi.AdminLogin = orDefault(cfg.AdminLogin, mi.AdminLogin) + mi.SKUName = orDefault(cfg.SKUName, mi.SKUName) + mi.SKUTier = orDefault(cfg.SKUTier, mi.SKUTier) + mi.LicenseType = orDefault(cfg.LicenseType, mi.LicenseType) + mi.SubnetID = orDefault(cfg.SubnetID, mi.SubnetID) + mi.VCores = orDefaultInt(cfg.VCores, mi.VCores) + mi.StorageGB = orDefaultInt(cfg.StorageGB, mi.StorageGB) + + if cfg.Tags != nil { + mi.Tags = copyTags(cfg.Tags) + } + + m.managedInstances.Set(cfg.Name, mi) + + out := mi + out.Tags = copyTags(mi.Tags) + + return &out, nil +} + +// GetManagedInstance returns a managed instance by name. +func (m *Mock) GetManagedInstance(_ context.Context, name string) (*rdsdriver.ManagedInstance, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + mi, ok := m.managedInstances.Get(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "managed instance %q not found", name) + } + + out := mi + out.Tags = copyTags(mi.Tags) + + return &out, nil +} + +// ListManagedInstances returns all managed instances. +func (m *Mock) ListManagedInstances(_ context.Context) ([]rdsdriver.ManagedInstance, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + out := []rdsdriver.ManagedInstance{} + + // SortedValues gives deterministic list ordering; Tags is cloned so a + // caller mutating the returned map can't corrupt the store. + mis := m.managedInstances.SortedValues() + for i := range mis { + mi := mis[i] + mi.Tags = copyTags(mi.Tags) + out = append(out, mi) + } + + return out, nil +} + +// DeleteManagedInstance removes a managed instance and cascades to its managed +// databases. +func (m *Mock) DeleteManagedInstance(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.managedInstances.Delete(name) { + return cerrors.Newf(cerrors.NotFound, "managed instance %q not found", name) + } + + prefix := name + "/" + for key := range m.managedDatabases.All() { + if strings.HasPrefix(key, prefix) { + m.managedDatabases.Delete(key) + } + } + + return nil +} + +// StartManagedInstance moves a stopped managed instance back to ready. +func (m *Mock) StartManagedInstance(ctx context.Context, name string) error { + return m.transitionManagedInstance(ctx, name, miStateStopped, miStateReady, "start") +} + +// StopManagedInstance moves a ready managed instance to stopped. +func (m *Mock) StopManagedInstance(ctx context.Context, name string) error { + return m.transitionManagedInstance(ctx, name, miStateReady, miStateStopped, "stop") +} + +// FailoverManagedInstance fails a managed instance over to its standby. It must +// be ready (real ECS/SQL rejects a failover on a stopped instance); it stays +// ready afterwards and re-emits metrics. +func (m *Mock) FailoverManagedInstance(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + mi, ok := m.managedInstances.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "managed instance %q not found", name) + } + + if mi.State != miStateReady { + return cerrors.Newf(cerrors.FailedPrecondition, + "managed instance %q is in state %q; failover requires %q", name, mi.State, miStateReady) + } + + m.emitManagedInstanceMetrics(name) + + return nil +} + +// transitionManagedInstance moves a managed instance from one state to another, +// no-op when already in the target state and a precondition error when it is in +// neither — matching the sibling flex/Cloud SQL lifecycle guards. +func (m *Mock) transitionManagedInstance(_ context.Context, name, from, to, verb string) error { + m.mu.Lock() + defer m.mu.Unlock() + + mi, ok := m.managedInstances.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "managed instance %q not found", name) + } + + if mi.State == to { + return nil + } + + if mi.State != from { + return cerrors.Newf(cerrors.FailedPrecondition, + "managed instance %q is in state %q; %s requires %q", name, mi.State, verb, from) + } + + mi.State = to + m.managedInstances.Set(name, mi) + + return nil +} + +// CreateManagedDatabase adds a database to a managed instance. +func (m *Mock) CreateManagedDatabase( + _ context.Context, cfg rdsdriver.ManagedDatabaseConfig, +) (*rdsdriver.ManagedDatabase, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "managed database name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.managedInstances.Get(cfg.Instance); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "managed instance %q not found", cfg.Instance) + } + + key := subKey(cfg.Instance, cfg.Name) + if _, ok := m.managedDatabases.Get(key); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "managed database %q already exists", cfg.Name) + } + + mdb := rdsdriver.ManagedDatabase{ + Instance: cfg.Instance, + Name: cfg.Name, + Collation: orDefault(cfg.Collation, "SQL_Latin1_General_CP1_CI_AS"), + Status: "Online", + ARN: m.mdbARN(cfg.Instance, cfg.Name), + } + + m.managedDatabases.Set(key, mdb) + + out := mdb + + return &out, nil +} + +// GetManagedDatabase returns a managed database. +func (m *Mock) GetManagedDatabase(_ context.Context, instance, name string) (*rdsdriver.ManagedDatabase, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + mdb, ok := m.managedDatabases.Get(subKey(instance, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "managed database %q not found", name) + } + + out := mdb + + return &out, nil +} + +// ListManagedDatabases returns all databases on a managed instance. +func (m *Mock) ListManagedDatabases(_ context.Context, instance string) ([]rdsdriver.ManagedDatabase, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if _, ok := m.managedInstances.Get(instance); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "managed instance %q not found", instance) + } + + out := []rdsdriver.ManagedDatabase{} + + for _, mdb := range m.managedDatabases.SortedValues() { + if mdb.Instance == instance { + out = append(out, mdb) + } + } + + return out, nil +} + +// DeleteManagedDatabase removes a managed database. +func (m *Mock) DeleteManagedDatabase(_ context.Context, instance, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.managedDatabases.Delete(subKey(instance, name)) { + return cerrors.Newf(cerrors.NotFound, "managed database %q not found", name) + } + + return nil +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + + return v +} + +func orDefaultInt(v, def int) int { + if v == 0 { + return def + } + + return v +} diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go new file mode 100644 index 00000000..68531bd5 --- /dev/null +++ b/providers/azure/azuresql/subresources.go @@ -0,0 +1,670 @@ +package azuresql + +import ( + "bytes" + "context" + "net" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// validIPv4 reports whether s parses as an IPv4 address. Azure SQL firewall +// rules require IPv4 start/end addresses. +func validIPv4(s string) bool { + ip := net.ParseIP(s) + return ip != nil && ip.To4() != nil +} + +// ipv4LessOrEqual reports whether start <= end by unsigned 32-bit value. Both +// must already be valid IPv4 (checked by validIPv4). +func ipv4LessOrEqual(start, end string) bool { + return bytes.Compare(net.ParseIP(start).To4(), net.ParseIP(end).To4()) <= 0 +} + +// Azure SQL exposes firewall rules, virtual-network rules, elastic pools, +// failover groups and an Azure AD administrator as server child resources. +// These are optional relationaldb driver capabilities discovered by the ARM +// handler via type assertion. +var ( + _ rdsdriver.FirewallRules = (*Mock)(nil) + _ rdsdriver.VNetRules = (*Mock)(nil) + _ rdsdriver.ElasticPools = (*Mock)(nil) + _ rdsdriver.FailoverGroups = (*Mock)(nil) + _ rdsdriver.AADAdmins = (*Mock)(nil) +) + +const ( + aadAdminName = "ActiveDirectory" + rolePrimary = "Primary" + roleSecondary = "Secondary" +) + +func subKey(server, name string) string { return server + "/" + name } + +// elasticPoolName extracts the pool name from an elasticPoolId, which may be a +// bare name or a full ARM resource ID ending in ".../elasticPools/{name}". +func elasticPoolName(id string) string { + if i := strings.LastIndex(id, "/elasticPools/"); i >= 0 { + return id[i+len("/elasticPools/"):] + } + + return id +} + +// requireElasticPool returns NotFound when a non-empty elastic-pool reference +// doesn't resolve to an existing pool on the server. Empty id is a no-op (a +// standalone database). Callers hold the write lock. +func (m *Mock) requireElasticPool(server, poolID string) error { + if poolID == "" { + return nil + } + + name := elasticPoolName(poolID) + if _, ok := m.elasticPools.Get(subKey(server, name)); !ok { + return cerrors.Newf(cerrors.NotFound, "elastic pool %q not found on server %q", name, server) + } + + return nil +} + +func (m *Mock) childARN(server, subType, name string) string { + return idgen.AzureID(m.opts.Region, m.opts.Region, armProvider, "servers/"+server+"/"+subType, name) +} + +func (m *Mock) requireServer(server string) error { + if _, ok := m.clusters.Get(server); !ok { + return cerrors.Newf(cerrors.NotFound, "Azure SQL server %q not found", server) + } + + return nil +} + +func cloneStrings(s []string) []string { + if len(s) == 0 { + return nil + } + + return append([]string(nil), s...) +} + +// ---- Firewall rules ---- + +// CreateFirewallRule creates or replaces a server firewall rule. +func (m *Mock) CreateFirewallRule( + _ context.Context, cfg rdsdriver.FirewallRuleConfig, +) (*rdsdriver.FirewallRule, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "firewall rule name is required") + } + + if !validIPv4(cfg.StartIPAddress) || !validIPv4(cfg.EndIPAddress) { + return nil, cerrors.New(cerrors.InvalidArgument, "startIpAddress and endIpAddress must be valid IPv4 addresses") + } + + if !ipv4LessOrEqual(cfg.StartIPAddress, cfg.EndIPAddress) { + return nil, cerrors.New(cerrors.InvalidArgument, "startIpAddress must be less than or equal to endIpAddress") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + rule := rdsdriver.FirewallRule{ + Server: cfg.Server, + Name: cfg.Name, + StartIPAddress: cfg.StartIPAddress, + EndIPAddress: cfg.EndIPAddress, + ARN: m.childARN(cfg.Server, "firewallRules", cfg.Name), + } + + m.firewallRules.Set(subKey(cfg.Server, cfg.Name), rule) + + out := rule + + return &out, nil +} + +// GetFirewallRule returns a single firewall rule. +func (m *Mock) GetFirewallRule(_ context.Context, server, name string) (*rdsdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rule, ok := m.firewallRules.Get(subKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + out := rule + + return &out, nil +} + +// ListFirewallRules returns all firewall rules on a server. +func (m *Mock) ListFirewallRules(_ context.Context, server string) ([]rdsdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.FirewallRule{} + + for _, rule := range m.firewallRules.SortedValues() { + if rule.Server == server { + out = append(out, rule) + } + } + + return out, nil +} + +// DeleteFirewallRule removes a firewall rule. +func (m *Mock) DeleteFirewallRule(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.firewallRules.Delete(subKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + return nil +} + +// ---- Virtual network rules ---- + +// CreateVNetRule creates or replaces a virtual-network rule. +func (m *Mock) CreateVNetRule(_ context.Context, cfg rdsdriver.VNetRuleConfig) (*rdsdriver.VNetRule, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "vnet rule name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + rule := rdsdriver.VNetRule{ + Server: cfg.Server, + Name: cfg.Name, + SubnetID: cfg.SubnetID, + IgnoreMissingEndpoint: cfg.IgnoreMissingEndpoint, + State: "Ready", + ARN: m.childARN(cfg.Server, "virtualNetworkRules", cfg.Name), + } + + m.vnetRules.Set(subKey(cfg.Server, cfg.Name), rule) + + out := rule + + return &out, nil +} + +// GetVNetRule returns a single virtual-network rule. +func (m *Mock) GetVNetRule(_ context.Context, server, name string) (*rdsdriver.VNetRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rule, ok := m.vnetRules.Get(subKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "vnet rule %q not found", name) + } + + out := rule + + return &out, nil +} + +// ListVNetRules returns all virtual-network rules on a server. +func (m *Mock) ListVNetRules(_ context.Context, server string) ([]rdsdriver.VNetRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.VNetRule{} + + for _, rule := range m.vnetRules.SortedValues() { + if rule.Server == server { + out = append(out, rule) + } + } + + return out, nil +} + +// DeleteVNetRule removes a virtual-network rule. +func (m *Mock) DeleteVNetRule(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.vnetRules.Delete(subKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "vnet rule %q not found", name) + } + + return nil +} + +// ---- Elastic pools ---- + +// CreateElasticPool creates or replaces an elastic pool. +// +//nolint:gocritic // cfg matches the ElasticPools capability interface signature. +func (m *Mock) CreateElasticPool(_ context.Context, cfg rdsdriver.ElasticPoolConfig) (*rdsdriver.ElasticPool, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "elastic pool name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + location := cfg.Location + if location == "" { + location = m.opts.Region + } + + pool := rdsdriver.ElasticPool{ + Server: cfg.Server, + Name: cfg.Name, + Location: location, + SKUName: cfg.SKUName, + SKUTier: cfg.SKUTier, + MaxSizeBytes: cfg.MaxSizeBytes, + MinCapacity: cfg.MinCapacity, + MaxCapacity: cfg.MaxCapacity, + State: "Ready", + ARN: m.childARN(cfg.Server, "elasticPools", cfg.Name), + } + + m.elasticPools.Set(subKey(cfg.Server, cfg.Name), pool) + + m.emitElasticPoolMetrics(cfg.Server, cfg.Name) + + out := pool + + return &out, nil +} + +// emitElasticPoolMetrics pushes a representative datapoint set on the +// Microsoft.Sql/servers/elasticpools namespace, matching the pool-scoped +// metrics real Azure Monitor surfaces. +func (m *Mock) emitElasticPoolMetrics(server, name string) { + if m.monitoring == nil { + return + } + + const ns = "Microsoft.Sql/servers/elasticpools" + + now := m.opts.Clock.Now() + dims := map[string]string{"resourceId": m.childARN(server, "elasticPools", name)} + + _ = m.monitoring.PutMetricData(context.Background(), []mondriver.MetricDatum{ + {Namespace: ns, MetricName: "cpu_percent", Value: 25, Unit: "Percent", Dimensions: dims, Timestamp: now}, + {Namespace: ns, MetricName: "storage_percent", Value: 25, Unit: "Percent", Dimensions: dims, Timestamp: now}, + {Namespace: ns, MetricName: "workers_percent", Value: 10, Unit: "Percent", Dimensions: dims, Timestamp: now}, + }) +} + +// GetElasticPool returns a single elastic pool. +func (m *Mock) GetElasticPool(_ context.Context, server, name string) (*rdsdriver.ElasticPool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + pool, ok := m.elasticPools.Get(subKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "elastic pool %q not found", name) + } + + out := pool + + return &out, nil +} + +// ListElasticPools returns all elastic pools on a server. +func (m *Mock) ListElasticPools(_ context.Context, server string) ([]rdsdriver.ElasticPool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.ElasticPool{} + + pools := m.elasticPools.SortedValues() + for i := range pools { + if pools[i].Server == server { + out = append(out, pools[i]) + } + } + + return out, nil +} + +// DeleteElasticPool removes an elastic pool. Like real Azure, it fails with a +// precondition error while the pool still contains databases. +func (m *Mock) DeleteElasticPool(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.elasticPools.Get(subKey(server, name)); !ok { + return cerrors.Newf(cerrors.NotFound, "elastic pool %q not found", name) + } + + suffix := "/elasticPools/" + name + + insts := m.instances.SortedValues() + for i := range insts { + if insts[i].ClusterID != server { + continue + } + + if insts[i].ElasticPoolID == name || strings.HasSuffix(insts[i].ElasticPoolID, suffix) { + return cerrors.Newf(cerrors.FailedPrecondition, + "elastic pool %q cannot be deleted while it contains databases", name) + } + } + + m.elasticPools.Delete(subKey(server, name)) + + return nil +} + +// UpdateElasticPool applies the non-zero fields of cfg to an existing pool +// (PATCH merge semantics), leaving unspecified fields untouched. +// +//nolint:gocritic // cfg matches the ElasticPools capability interface signature. +func (m *Mock) UpdateElasticPool(_ context.Context, cfg rdsdriver.ElasticPoolConfig) (*rdsdriver.ElasticPool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + key := subKey(cfg.Server, cfg.Name) + + pool, ok := m.elasticPools.Get(key) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "elastic pool %q not found", cfg.Name) + } + + if cfg.Location != "" { + pool.Location = cfg.Location + } + + if cfg.SKUName != "" { + pool.SKUName = cfg.SKUName + } + + if cfg.SKUTier != "" { + pool.SKUTier = cfg.SKUTier + } + + if cfg.MaxSizeBytes != 0 { + pool.MaxSizeBytes = cfg.MaxSizeBytes + } + + if cfg.MinCapacity != 0 { + pool.MinCapacity = cfg.MinCapacity + } + + if cfg.MaxCapacity != 0 { + pool.MaxCapacity = cfg.MaxCapacity + } + + m.elasticPools.Set(key, pool) + + out := pool + + return &out, nil +} + +// ---- Failover groups ---- + +// CreateFailoverGroup creates or replaces a failover group with the local +// server as primary. +// +//nolint:gocritic // cfg matches the FailoverGroups capability interface signature. +func (m *Mock) CreateFailoverGroup( + _ context.Context, cfg rdsdriver.FailoverGroupConfig, +) (*rdsdriver.FailoverGroup, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "failover group name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + fg := rdsdriver.FailoverGroup{ + Server: cfg.Server, + Name: cfg.Name, + FailoverPolicy: cfg.FailoverPolicy, + GracePeriodMinutes: cfg.GracePeriodMinutes, + PartnerServers: cloneStrings(cfg.PartnerServers), + Databases: cloneStrings(cfg.Databases), + ReplicationRole: rolePrimary, + ARN: m.childARN(cfg.Server, "failoverGroups", cfg.Name), + } + + m.failoverGroups.Set(subKey(cfg.Server, cfg.Name), fg) + + return copyFailoverGroup(fg), nil +} + +// GetFailoverGroup returns a single failover group. +func (m *Mock) GetFailoverGroup(_ context.Context, server, name string) (*rdsdriver.FailoverGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + fg, ok := m.failoverGroups.Get(subKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "failover group %q not found", name) + } + + return copyFailoverGroup(fg), nil +} + +// ListFailoverGroups returns all failover groups on a server. +func (m *Mock) ListFailoverGroups(_ context.Context, server string) ([]rdsdriver.FailoverGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.FailoverGroup{} + + fgs := m.failoverGroups.SortedValues() + for i := range fgs { + if fgs[i].Server == server { + out = append(out, *copyFailoverGroup(fgs[i])) + } + } + + return out, nil +} + +// DeleteFailoverGroup removes a failover group. +func (m *Mock) DeleteFailoverGroup(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.failoverGroups.Delete(subKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "failover group %q not found", name) + } + + return nil +} + +// FailoverFailoverGroup flips the local replication role between Primary and +// Secondary, modeling a planned failover. +func (m *Mock) FailoverFailoverGroup(_ context.Context, server, name string) (*rdsdriver.FailoverGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + key := subKey(server, name) + + fg, ok := m.failoverGroups.Get(key) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "failover group %q not found", name) + } + + // A failover only makes sense when there is a partner server to promote to; + // otherwise a standalone group would ping-pong between Primary and Secondary + // and leave a Secondary with no Primary. + if len(fg.PartnerServers) == 0 { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "failover group %q has no partner server to fail over to", name) + } + + if fg.ReplicationRole == rolePrimary { + fg.ReplicationRole = roleSecondary + } else { + fg.ReplicationRole = rolePrimary + } + + fg.PartnerServers = cloneStrings(fg.PartnerServers) + fg.Databases = cloneStrings(fg.Databases) + m.failoverGroups.Set(key, fg) + + return copyFailoverGroup(fg), nil +} + +//nolint:gocritic // fg is copied by value to produce an isolated result. +func copyFailoverGroup(fg rdsdriver.FailoverGroup) *rdsdriver.FailoverGroup { + fg.PartnerServers = cloneStrings(fg.PartnerServers) + fg.Databases = cloneStrings(fg.Databases) + + return &fg +} + +// UpdateFailoverGroup applies the non-zero fields of cfg to an existing group +// (PATCH merge semantics). Partner/database lists are replaced only when the +// PATCH supplies them. +// +//nolint:gocritic // cfg matches the FailoverGroups capability interface signature. +func (m *Mock) UpdateFailoverGroup( + _ context.Context, cfg rdsdriver.FailoverGroupConfig, +) (*rdsdriver.FailoverGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + key := subKey(cfg.Server, cfg.Name) + + fg, ok := m.failoverGroups.Get(key) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "failover group %q not found", cfg.Name) + } + + if cfg.FailoverPolicy != "" { + fg.FailoverPolicy = cfg.FailoverPolicy + } + + if cfg.GracePeriodMinutes != 0 { + fg.GracePeriodMinutes = cfg.GracePeriodMinutes + } + + if len(cfg.PartnerServers) > 0 { + fg.PartnerServers = cloneStrings(cfg.PartnerServers) + } else { + fg.PartnerServers = cloneStrings(fg.PartnerServers) + } + + if len(cfg.Databases) > 0 { + fg.Databases = cloneStrings(cfg.Databases) + } else { + fg.Databases = cloneStrings(fg.Databases) + } + + m.failoverGroups.Set(key, fg) + + return copyFailoverGroup(fg), nil +} + +// ---- Azure AD administrator ---- + +// SetAADAdmin sets the server's Azure AD administrator (there is at most one). +func (m *Mock) SetAADAdmin(_ context.Context, cfg rdsdriver.AADAdminConfig) (*rdsdriver.AADAdmin, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + admin := rdsdriver.AADAdmin{ + Server: cfg.Server, + Name: aadAdminName, + Login: cfg.Login, + SID: cfg.SID, + TenantID: cfg.TenantID, + ARN: m.childARN(cfg.Server, "administrators", aadAdminName), + } + + m.aadAdmins.Set(cfg.Server, admin) + + out := admin + + return &out, nil +} + +// GetAADAdmin returns the server's Azure AD administrator. +func (m *Mock) GetAADAdmin(_ context.Context, server, _ string) (*rdsdriver.AADAdmin, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + admin, ok := m.aadAdmins.Get(server) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "Azure AD administrator not set on server %q", server) + } + + out := admin + + return &out, nil +} + +// ListAADAdmins returns the server's Azure AD administrator as a list (0 or 1). +func (m *Mock) ListAADAdmins(_ context.Context, server string) ([]rdsdriver.AADAdmin, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + admin, ok := m.aadAdmins.Get(server) + if !ok { + return []rdsdriver.AADAdmin{}, nil + } + + return []rdsdriver.AADAdmin{admin}, nil +} + +// DeleteAADAdmin removes the server's Azure AD administrator. +func (m *Mock) DeleteAADAdmin(_ context.Context, server, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.aadAdmins.Delete(server) { + return cerrors.Newf(cerrors.NotFound, "Azure AD administrator not set on server %q", server) + } + + return nil +} diff --git a/providers/azure/mysqlflex/mysqlflex.go b/providers/azure/mysqlflex/mysqlflex.go index cbc4108a..72f464be 100644 --- a/providers/azure/mysqlflex/mysqlflex.go +++ b/providers/azure/mysqlflex/mysqlflex.go @@ -12,6 +12,7 @@ package mysqlflex import ( "context" + "strings" "sync" "github.com/stackshy/cloudemu/v2/config" @@ -49,6 +50,10 @@ type Mock struct { instances *memstore.Store[rdsdriver.Instance] snapshots *memstore.Store[rdsdriver.Snapshot] + databases *memstore.Store[rdsdriver.Database] + firewallRules *memstore.Store[rdsdriver.FirewallRule] + configurations *memstore.Store[rdsdriver.Configuration] + opts *config.Options monitoring mondriver.Monitoring } @@ -56,9 +61,12 @@ type Mock struct { // New creates a new MySQL Flexible Server mock. func New(opts *config.Options) *Mock { return &Mock{ - instances: memstore.New[rdsdriver.Instance](), - snapshots: memstore.New[rdsdriver.Snapshot](), - opts: opts, + instances: memstore.New[rdsdriver.Instance](), + snapshots: memstore.New[rdsdriver.Snapshot](), + databases: memstore.New[rdsdriver.Database](), + firewallRules: memstore.New[rdsdriver.FirewallRule](), + configurations: memstore.New[rdsdriver.Configuration](), + opts: opts, } } @@ -217,6 +225,8 @@ func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.I } // ModifyInstance applies the supplied changes. +// +//nolint:gocritic // input matches the driver interface signature. func (m *Mock) ModifyInstance( _ context.Context, id string, input rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Instance, error) { @@ -264,9 +274,36 @@ func (m *Mock) DeleteInstance(_ context.Context, id string) error { return cerrors.Newf(cerrors.NotFound, "MySQL Flexible Server %q not found", id) } + m.deleteChildren(id) + return nil } +// deleteChildren removes the databases, firewall rules and configurations that +// belong to server id, matching Azure's cascade delete on server removal. The +// caller already holds the write lock. +func (m *Mock) deleteChildren(server string) { + prefix := server + "/" + + for key := range m.databases.All() { + if strings.HasPrefix(key, prefix) { + m.databases.Delete(key) + } + } + + for key := range m.firewallRules.All() { + if strings.HasPrefix(key, prefix) { + m.firewallRules.Delete(key) + } + } + + for key := range m.configurations.All() { + if strings.HasPrefix(key, prefix) { + m.configurations.Delete(key) + } + } +} + // StartInstance moves a stopped server back to runnable. func (m *Mock) StartInstance(_ context.Context, id string) error { return m.transitionInstance(id, rdsdriver.StateStopped, rdsdriver.StateAvailable, @@ -341,6 +378,8 @@ func (*Mock) DescribeClusters(_ context.Context, _ []string) ([]rdsdriver.Cluste } // ModifyCluster is unsupported on MySQL Flexible Server. +// +//nolint:gocritic // input matches the driver interface signature. func (*Mock) ModifyCluster( _ context.Context, _ string, _ rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Cluster, error) { diff --git a/providers/azure/mysqlflex/mysqlflex_test.go b/providers/azure/mysqlflex/mysqlflex_test.go index 218cc2f5..9a6a64f2 100644 --- a/providers/azure/mysqlflex/mysqlflex_test.go +++ b/providers/azure/mysqlflex/mysqlflex_test.go @@ -263,3 +263,221 @@ func assertNotEmpty(t *testing.T, s string) { t.Error("expected non-empty string") } } + +func TestSubResourcesRequireServer(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "ghost", Name: "db"}); err == nil { + t.Error("CreateDatabase on missing server: expected error") + } + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "ghost", Name: "r"}); err == nil { + t.Error("CreateFirewallRule on missing server: expected error") + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "ghost", Name: "max_connections", Value: "100"}); err == nil { + t.Error("SetConfiguration on missing server: expected error") + } +} + +func TestDatabaseLifecycleAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv", Name: "app"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv", Name: "app"}); err == nil { + t.Error("duplicate database: expected AlreadyExists") + } + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r", StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.9"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "100"}); err != nil { + t.Fatalf("SetConfiguration: %v", err) + } + + // Deleting the server cascades to its children. + if err := m.DeleteInstance(ctx, "srv"); err != nil { + t.Fatalf("DeleteInstance: %v", err) + } + + if _, err := m.ListDatabases(ctx, "srv"); err == nil { + t.Error("ListDatabases after server delete: expected server NotFound") + } +} + +func TestSetConfigurationValidatesParameter(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Unknown parameter names and empty values are both rejected (real Azure + // 404s an unknown server parameter). + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "not_a_real_param", Value: "1"}); err == nil { + t.Error("SetConfiguration with unknown parameter: expected NotFound") + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: ""}); err == nil { + t.Error("SetConfiguration with empty value: expected InvalidArgument") + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "200"}); err != nil { + t.Errorf("SetConfiguration with known parameter: %v", err) + } + + // A known-but-unset parameter returns its catalog default, not NotFound. + def, err := m.GetConfiguration(ctx, "srv", "wait_timeout") + if err != nil { + t.Fatalf("GetConfiguration for unset known param: %v", err) + } + + if def.Source != "system-default" || def.Value == "" { + t.Errorf("expected catalog default for wait_timeout, got %+v", def) + } + + // An unknown parameter still 404s. + if _, err := m.GetConfiguration(ctx, "srv", "not_a_real_param"); err == nil { + t.Error("GetConfiguration for unknown param: expected NotFound") + } +} + +func TestFailoverRequiresRunning(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + if err := m.FailoverInstance(ctx, "srv"); err != nil { + t.Fatalf("FailoverInstance on running server: %v", err) + } + + if err := m.StopInstance(ctx, "srv"); err != nil { + t.Fatalf("StopInstance: %v", err) + } + + if err := m.FailoverInstance(ctx, "srv"); err == nil { + t.Error("FailoverInstance on stopped server: expected FailedPrecondition") + } + + if err := m.FailoverInstance(ctx, "ghost"); err == nil { + t.Error("FailoverInstance on missing server: expected NotFound") + } +} + +func TestSubResourceCRUDCoverage(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Databases: get + delete. + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv", Name: "app"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if got, err := m.GetDatabase(ctx, "srv", "app"); err != nil || got.Name != "app" { + t.Fatalf("GetDatabase: %+v %v", got, err) + } + + if err := m.DeleteDatabase(ctx, "srv", "app"); err != nil { + t.Fatalf("DeleteDatabase: %v", err) + } + + if err := m.DeleteDatabase(ctx, "srv", "app"); err == nil { + t.Error("DeleteDatabase again: expected NotFound") + } + + // Firewall rules: get, list, delete. + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r", StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.9"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if got, err := m.GetFirewallRule(ctx, "srv", "r"); err != nil || got.EndIPAddress != "10.0.0.9" { + t.Fatalf("GetFirewallRule: %+v %v", got, err) + } + + if rs, err := m.ListFirewallRules(ctx, "srv"); err != nil || len(rs) != 1 { + t.Fatalf("ListFirewallRules: %d %v", len(rs), err) + } + + if err := m.DeleteFirewallRule(ctx, "srv", "r"); err != nil { + t.Fatalf("DeleteFirewallRule: %v", err) + } + + if err := m.DeleteFirewallRule(ctx, "srv", "r"); err == nil { + t.Error("DeleteFirewallRule again: expected NotFound") + } + + // Configurations: set (known param), get, list. + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "100"}); err != nil { + t.Fatalf("SetConfiguration: %v", err) + } + + if got, err := m.GetConfiguration(ctx, "srv", "max_connections"); err != nil || got.Value != "100" { + t.Fatalf("GetConfiguration: %+v %v", got, err) + } + + // List returns the full catalog (with the override applied), not just the + // single written parameter. + if cs, err := m.ListConfigurations(ctx, "srv"); err != nil || len(cs) < 2 { + t.Fatalf("ListConfigurations: %d %v", len(cs), err) + } +} + +func TestBatchSetConfigurationsIsAtomic(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // A batch with a good entry followed by an unknown-parameter entry must + // apply nothing (the good entry keeps its catalog default). + _, err := m.BatchSetConfigurations(ctx, "srv", []rdsdriver.ConfigurationConfig{ + {Name: "max_connections", Value: "500"}, + {Name: "not_a_real_param", Value: "x"}, + }) + if err == nil { + t.Fatal("BatchSetConfigurations with a bad entry: expected error") + } + + got, err := m.GetConfiguration(ctx, "srv", "max_connections") + if err != nil { + t.Fatalf("GetConfiguration: %v", err) + } + + if got.Source != "system-default" { + t.Errorf("batch was not atomic: max_connections was persisted as %q (source %q)", got.Value, got.Source) + } + + // A fully-valid batch applies all entries. + if _, err := m.BatchSetConfigurations(ctx, "srv", []rdsdriver.ConfigurationConfig{ + {Name: "max_connections", Value: "500"}, + {Name: "slow_query_log", Value: "ON"}, + }); err != nil { + t.Fatalf("BatchSetConfigurations (valid): %v", err) + } + + mc, _ := m.GetConfiguration(ctx, "srv", "max_connections") + sl, _ := m.GetConfiguration(ctx, "srv", "slow_query_log") + if mc.Value != "500" || sl.Value != "ON" { + t.Errorf("valid batch not applied: max_connections=%q slow_query_log=%q", mc.Value, sl.Value) + } +} diff --git a/providers/azure/mysqlflex/subresources.go b/providers/azure/mysqlflex/subresources.go new file mode 100644 index 00000000..b91a47c5 --- /dev/null +++ b/providers/azure/mysqlflex/subresources.go @@ -0,0 +1,442 @@ +package mysqlflex + +import ( + "bytes" + "context" + "net" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// validIPv4 reports whether s parses as an IPv4 address. +func validIPv4(s string) bool { + ip := net.ParseIP(s) + return ip != nil && ip.To4() != nil +} + +// ipv4LessOrEqual reports whether start <= end by unsigned 32-bit value. Both +// must already be valid IPv4 (checked by validIPv4). +func ipv4LessOrEqual(start, end string) bool { + return bytes.Compare(net.ParseIP(start).To4(), net.ParseIP(end).To4()) <= 0 +} + +// MySQL Flexible Server exposes databases, firewall rules and server +// configurations as child resources. These are optional relationaldb driver +// capabilities discovered by the ARM handler via type assertion. +var ( + _ rdsdriver.Databases = (*Mock)(nil) + _ rdsdriver.FirewallRules = (*Mock)(nil) + _ rdsdriver.Configurations = (*Mock)(nil) + _ rdsdriver.BatchConfigurations = (*Mock)(nil) + _ rdsdriver.Failover = (*Mock)(nil) +) + +const defaultCollation = "utf8mb4_general_ci" + +// knownServerParameters is a representative subset of the MySQL Flexible Server +// parameter catalog mapped to its server default. Azure rejects +// SetConfiguration for a name outside the catalog with 404 (so the mock +// validates against this set rather than accept-and-echo any name), and returns +// the default via Get/List for a known-but-unset parameter rather than 404. +// +//nolint:gochecknoglobals // immutable parameter-name lookup table. +var knownServerParameters = map[string]string{ + "max_connections": "151", + "wait_timeout": "28800", + "interactive_timeout": "28800", + "slow_query_log": "OFF", + "long_query_time": "10", + "innodb_buffer_pool_size": "134217728", + "character_set_server": "utf8mb4", + "collation_server": "utf8mb4_general_ci", + "time_zone": "SYSTEM", + "sql_mode": "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION", + "event_scheduler": "OFF", + "log_bin_trust_function_creators": "OFF", + "max_allowed_packet": "67108864", + "innodb_lock_wait_timeout": "50", +} + +func childKey(server, name string) string { return server + "/" + name } + +func (m *Mock) childARN(server, subType, name string) string { + return idgen.AzureID(m.opts.AccountID, resourceGroupTag, providerNamespace, + resourceTypeFlexible+"/"+server+"/"+subType, name) +} + +// requireServer returns NotFound when server does not exist. Callers hold the +// lock appropriate to their operation. +func (m *Mock) requireServer(server string) error { + if _, ok := m.instances.Get(server); !ok { + return cerrors.Newf(cerrors.NotFound, "MySQL Flexible Server %q not found", server) + } + + return nil +} + +// ---- Databases ---- + +// CreateDatabase adds a logical database to a server. +func (m *Mock) CreateDatabase(_ context.Context, cfg rdsdriver.DatabaseConfig) (*rdsdriver.Database, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "database name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + key := childKey(cfg.Server, cfg.Name) + if _, ok := m.databases.Get(key); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "database %q already exists", cfg.Name) + } + + collation := cfg.Collation + if collation == "" { + collation = defaultCollation + } + + db := rdsdriver.Database{ + Server: cfg.Server, + Name: cfg.Name, + Charset: cfg.Charset, + Collation: collation, + ARN: m.childARN(cfg.Server, "databases", cfg.Name), + } + + m.databases.Set(key, db) + + out := db + + return &out, nil +} + +// GetDatabase returns a single logical database. +func (m *Mock) GetDatabase(_ context.Context, server, name string) (*rdsdriver.Database, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + db, ok := m.databases.Get(childKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "database %q not found", name) + } + + out := db + + return &out, nil +} + +// ListDatabases returns all logical databases in a server. +func (m *Mock) ListDatabases(_ context.Context, server string) ([]rdsdriver.Database, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.Database{} + + for _, db := range m.databases.SortedValues() { + if db.Server == server { + out = append(out, db) + } + } + + return out, nil +} + +// DeleteDatabase removes a logical database. +func (m *Mock) DeleteDatabase(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.databases.Delete(childKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "database %q not found", name) + } + + return nil +} + +// ---- Firewall rules ---- + +// CreateFirewallRule creates or replaces a server firewall rule. +func (m *Mock) CreateFirewallRule( + _ context.Context, cfg rdsdriver.FirewallRuleConfig, +) (*rdsdriver.FirewallRule, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "firewall rule name is required") + } + + if !validIPv4(cfg.StartIPAddress) || !validIPv4(cfg.EndIPAddress) { + return nil, cerrors.New(cerrors.InvalidArgument, "startIpAddress and endIpAddress must be valid IPv4 addresses") + } + + if !ipv4LessOrEqual(cfg.StartIPAddress, cfg.EndIPAddress) { + return nil, cerrors.New(cerrors.InvalidArgument, "startIpAddress must be less than or equal to endIpAddress") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + rule := rdsdriver.FirewallRule{ + Server: cfg.Server, + Name: cfg.Name, + StartIPAddress: cfg.StartIPAddress, + EndIPAddress: cfg.EndIPAddress, + ARN: m.childARN(cfg.Server, "firewallRules", cfg.Name), + } + + m.firewallRules.Set(childKey(cfg.Server, cfg.Name), rule) + + out := rule + + return &out, nil +} + +// GetFirewallRule returns a single firewall rule. +func (m *Mock) GetFirewallRule(_ context.Context, server, name string) (*rdsdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rule, ok := m.firewallRules.Get(childKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + out := rule + + return &out, nil +} + +// ListFirewallRules returns all firewall rules on a server. +func (m *Mock) ListFirewallRules(_ context.Context, server string) ([]rdsdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.FirewallRule{} + + for _, rule := range m.firewallRules.SortedValues() { + if rule.Server == server { + out = append(out, rule) + } + } + + return out, nil +} + +// DeleteFirewallRule removes a firewall rule. +func (m *Mock) DeleteFirewallRule(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.firewallRules.Delete(childKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + return nil +} + +// ---- Configurations (server parameters) ---- + +// validateConfiguration checks a single parameter without applying it: name +// must be a known catalog entry and the value non-empty. Server existence is +// checked separately by the caller under the lock. +func validateConfiguration(cfg rdsdriver.ConfigurationConfig) error { + if cfg.Name == "" { + return cerrors.New(cerrors.InvalidArgument, "configuration name is required") + } + + if _, known := knownServerParameters[cfg.Name]; !known { + return cerrors.Newf(cerrors.NotFound, "unknown server parameter %q", cfg.Name) + } + + if cfg.Value == "" { + return cerrors.New(cerrors.InvalidArgument, "configuration value is required") + } + + return nil +} + +// applyConfiguration writes one validated parameter. The caller holds the write +// lock and has already validated cfg + server existence. +func (m *Mock) applyConfiguration(cfg rdsdriver.ConfigurationConfig) rdsdriver.Configuration { + key := childKey(cfg.Server, cfg.Name) + + conf, ok := m.configurations.Get(key) + if !ok { + conf = rdsdriver.Configuration{ + Server: cfg.Server, + Name: cfg.Name, + DataType: "String", + ARN: m.childARN(cfg.Server, "configurations", cfg.Name), + } + } + + conf.Value = cfg.Value + conf.Source = "user-override" + + m.configurations.Set(key, conf) + + return conf +} + +// SetConfiguration sets a server parameter value, recording it as a user +// override. +func (m *Mock) SetConfiguration( + _ context.Context, cfg rdsdriver.ConfigurationConfig, +) (*rdsdriver.Configuration, error) { + if err := validateConfiguration(cfg); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + out := m.applyConfiguration(cfg) + + return &out, nil +} + +// BatchSetConfigurations applies several parameters atomically: every entry is +// validated (and the server checked) before any write, so a bad entry never +// leaves earlier ones persisted. +func (m *Mock) BatchSetConfigurations( + _ context.Context, server string, cfgs []rdsdriver.ConfigurationConfig, +) ([]rdsdriver.Configuration, error) { + for i := range cfgs { + cfgs[i].Server = server + if err := validateConfiguration(cfgs[i]); err != nil { + return nil, err + } + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := make([]rdsdriver.Configuration, 0, len(cfgs)) + for i := range cfgs { + out = append(out, m.applyConfiguration(cfgs[i])) + } + + return out, nil +} + +// GetConfiguration returns a server parameter — the user override if one was +// set, otherwise the catalog default for a known parameter (real Azure returns +// the system default for an unset-but-valid parameter). Unknown parameters 404. +func (m *Mock) GetConfiguration(_ context.Context, server, name string) (*rdsdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if conf, ok := m.configurations.Get(childKey(server, name)); ok { + out := conf + + return &out, nil + } + + def, known := knownServerParameters[name] + if !known { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + return m.defaultConfiguration(server, name, def), nil +} + +// defaultConfiguration builds the system-default view of a known parameter. +func (m *Mock) defaultConfiguration(server, name, value string) *rdsdriver.Configuration { + return &rdsdriver.Configuration{ + Server: server, + Name: name, + Value: value, + Source: "system-default", + DataType: "String", + ARN: m.childARN(server, "configurations", name), + } +} + +// ListConfigurations returns the full parameter catalog, with user overrides +// applied where present (real Azure lists the catalog with defaults, not just +// the parameters that have been written). +func (m *Mock) ListConfigurations(_ context.Context, server string) ([]rdsdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + overrides := make(map[string]rdsdriver.Configuration) + + confs := m.configurations.SortedValues() + for i := range confs { + if confs[i].Server == server { + overrides[confs[i].Name] = confs[i] + } + } + + names := make([]string, 0, len(knownServerParameters)) + for name := range knownServerParameters { + names = append(names, name) + } + + sort.Strings(names) + + out := make([]rdsdriver.Configuration, 0, len(names)) + + for _, name := range names { + if conf, ok := overrides[name]; ok { + out = append(out, conf) + continue + } + + out = append(out, *m.defaultConfiguration(server, name, knownServerParameters[name])) + } + + return out, nil +} + +// ---- Failover ---- + +// FailoverInstance triggers a server failover to its standby. The server must +// be running; it stays available afterwards. +func (m *Mock) FailoverInstance(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + inst, ok := m.instances.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "MySQL Flexible Server %q not found", id) + } + + if inst.State != rdsdriver.StateAvailable { + return cerrors.Newf(cerrors.FailedPrecondition, + "MySQL Flexible Server %q is in state %q; failover requires %q", id, inst.State, rdsdriver.StateAvailable) + } + + m.emitInstanceMetrics(id, cpuMetricRunning, connectionMetricValue, diskReadOpsRunning, diskWriteOpsRunning) + + return nil +} diff --git a/providers/azure/postgresflex/postgresflex.go b/providers/azure/postgresflex/postgresflex.go index 18ca63cf..17946a6a 100644 --- a/providers/azure/postgresflex/postgresflex.go +++ b/providers/azure/postgresflex/postgresflex.go @@ -17,6 +17,7 @@ package postgresflex import ( "context" "fmt" + "strings" "sync" "github.com/stackshy/cloudemu/v2/config" @@ -50,6 +51,10 @@ type Mock struct { instances *memstore.Store[rdsdriver.Instance] snapshots *memstore.Store[rdsdriver.Snapshot] + databases *memstore.Store[rdsdriver.Database] + firewallRules *memstore.Store[rdsdriver.FirewallRule] + configurations *memstore.Store[rdsdriver.Configuration] + opts *config.Options monitoring mondriver.Monitoring } @@ -57,9 +62,12 @@ type Mock struct { // New creates a new Postgres Flex mock. func New(opts *config.Options) *Mock { return &Mock{ - instances: memstore.New[rdsdriver.Instance](), - snapshots: memstore.New[rdsdriver.Snapshot](), - opts: opts, + instances: memstore.New[rdsdriver.Instance](), + snapshots: memstore.New[rdsdriver.Snapshot](), + databases: memstore.New[rdsdriver.Database](), + firewallRules: memstore.New[rdsdriver.FirewallRule](), + configurations: memstore.New[rdsdriver.Configuration](), + opts: opts, } } @@ -228,6 +236,8 @@ func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.I } // ModifyInstance applies the supplied changes. +// +//nolint:gocritic // input matches the driver interface signature. func (m *Mock) ModifyInstance( _ context.Context, id string, input rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Instance, error) { @@ -275,9 +285,36 @@ func (m *Mock) DeleteInstance(_ context.Context, id string) error { return cerrors.Newf(cerrors.NotFound, "Postgres Flex server %q not found", id) } + m.deleteChildren(id) + return nil } +// deleteChildren removes the databases, firewall rules and configurations that +// belong to server id, matching Azure's cascade delete on server removal. The +// caller already holds the write lock. +func (m *Mock) deleteChildren(server string) { + prefix := server + "/" + + for key := range m.databases.All() { + if strings.HasPrefix(key, prefix) { + m.databases.Delete(key) + } + } + + for key := range m.firewallRules.All() { + if strings.HasPrefix(key, prefix) { + m.firewallRules.Delete(key) + } + } + + for key := range m.configurations.All() { + if strings.HasPrefix(key, prefix) { + m.configurations.Delete(key) + } + } +} + // StartInstance moves a stopped server back to running. func (m *Mock) StartInstance(_ context.Context, id string) error { return m.transitionInstance(id, rdsdriver.StateStopped, rdsdriver.StateAvailable, cpuMetricRunning, connRunning, "start") @@ -350,6 +387,8 @@ func (*Mock) DescribeClusters(_ context.Context, _ []string) ([]rdsdriver.Cluste } // ModifyCluster is unsupported on Postgres Flex. +// +//nolint:gocritic // input matches the driver interface signature. func (*Mock) ModifyCluster( _ context.Context, _ string, _ rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Cluster, error) { diff --git a/providers/azure/postgresflex/postgresflex_test.go b/providers/azure/postgresflex/postgresflex_test.go index f5970bfe..0c73a806 100644 --- a/providers/azure/postgresflex/postgresflex_test.go +++ b/providers/azure/postgresflex/postgresflex_test.go @@ -271,3 +271,157 @@ func assertNotEmpty(t *testing.T, s string) { t.Error("expected non-empty string") } } + +func TestSubResourcesRequireServer(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "ghost", Name: "db"}); err == nil { + t.Error("CreateDatabase on missing server: expected error") + } + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "ghost", Name: "r"}); err == nil { + t.Error("CreateFirewallRule on missing server: expected error") + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "ghost", Name: "max_connections", Value: "100"}); err == nil { + t.Error("SetConfiguration on missing server: expected error") + } +} + +func TestDatabaseDefaultsAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + db, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv", Name: "app"}) + if err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if db.Charset != "UTF8" || db.Collation != "en_US.utf8" { + t.Errorf("defaults: got charset=%q collation=%q", db.Charset, db.Collation) + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv", Name: "app"}); err == nil { + t.Error("duplicate database: expected AlreadyExists") + } + + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r", StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.9"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "100"}); err != nil { + t.Fatalf("SetConfiguration: %v", err) + } + + if err := m.DeleteInstance(ctx, "srv"); err != nil { + t.Fatalf("DeleteInstance: %v", err) + } + + if _, err := m.ListDatabases(ctx, "srv"); err == nil { + t.Error("ListDatabases after server delete: expected server NotFound") + } +} + +func TestSetConfigurationValidatesParameter(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Unknown parameter names and empty values are both rejected (real Azure + // 404s an unknown server parameter). + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "not_a_real_param", Value: "1"}); err == nil { + t.Error("SetConfiguration with unknown parameter: expected NotFound") + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "work_mem", Value: ""}); err == nil { + t.Error("SetConfiguration with empty value: expected InvalidArgument") + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "work_mem", Value: "4MB"}); err != nil { + t.Errorf("SetConfiguration with known parameter: %v", err) + } + + // A known-but-unset parameter returns its catalog default, not NotFound. + def, err := m.GetConfiguration(ctx, "srv", "max_connections") + if err != nil { + t.Fatalf("GetConfiguration for unset known param: %v", err) + } + + if def.Source != "system-default" || def.Value == "" { + t.Errorf("expected catalog default for max_connections, got %+v", def) + } + + if _, err := m.GetConfiguration(ctx, "srv", "not_a_real_param"); err == nil { + t.Error("GetConfiguration for unknown param: expected NotFound") + } +} + +func TestSubResourceCRUDCoverage(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "srv"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Databases: get + delete. + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv", Name: "app"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if got, err := m.GetDatabase(ctx, "srv", "app"); err != nil || got.Name != "app" { + t.Fatalf("GetDatabase: %+v %v", got, err) + } + + if err := m.DeleteDatabase(ctx, "srv", "app"); err != nil { + t.Fatalf("DeleteDatabase: %v", err) + } + + if err := m.DeleteDatabase(ctx, "srv", "app"); err == nil { + t.Error("DeleteDatabase again: expected NotFound") + } + + // Firewall rules: get, list, delete. + if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r", StartIPAddress: "10.0.0.1", EndIPAddress: "10.0.0.9"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if got, err := m.GetFirewallRule(ctx, "srv", "r"); err != nil || got.EndIPAddress != "10.0.0.9" { + t.Fatalf("GetFirewallRule: %+v %v", got, err) + } + + if rs, err := m.ListFirewallRules(ctx, "srv"); err != nil || len(rs) != 1 { + t.Fatalf("ListFirewallRules: %d %v", len(rs), err) + } + + if err := m.DeleteFirewallRule(ctx, "srv", "r"); err != nil { + t.Fatalf("DeleteFirewallRule: %v", err) + } + + if err := m.DeleteFirewallRule(ctx, "srv", "r"); err == nil { + t.Error("DeleteFirewallRule again: expected NotFound") + } + + // Configurations: set (known param), get, list. + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "100"}); err != nil { + t.Fatalf("SetConfiguration: %v", err) + } + + if got, err := m.GetConfiguration(ctx, "srv", "max_connections"); err != nil || got.Value != "100" { + t.Fatalf("GetConfiguration: %+v %v", got, err) + } + + // List returns the full catalog (with the override applied), not just the + // single written parameter. + if cs, err := m.ListConfigurations(ctx, "srv"); err != nil || len(cs) < 2 { + t.Fatalf("ListConfigurations: %d %v", len(cs), err) + } +} diff --git a/providers/azure/postgresflex/subresources.go b/providers/azure/postgresflex/subresources.go new file mode 100644 index 00000000..bb264e38 --- /dev/null +++ b/providers/azure/postgresflex/subresources.go @@ -0,0 +1,379 @@ +package postgresflex + +import ( + "bytes" + "context" + "net" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// validIPv4 reports whether s parses as an IPv4 address. +func validIPv4(s string) bool { + ip := net.ParseIP(s) + return ip != nil && ip.To4() != nil +} + +// ipv4LessOrEqual reports whether start <= end by unsigned 32-bit value. Both +// must already be valid IPv4 (checked by validIPv4). +func ipv4LessOrEqual(start, end string) bool { + return bytes.Compare(net.ParseIP(start).To4(), net.ParseIP(end).To4()) <= 0 +} + +// Postgres Flexible Server exposes databases, firewall rules and server +// configurations as child resources. These are optional relationaldb driver +// capabilities discovered by the ARM handler via type assertion. Unlike MySQL +// Flex, Postgres Flex has no failover action. +var ( + _ rdsdriver.Databases = (*Mock)(nil) + _ rdsdriver.FirewallRules = (*Mock)(nil) + _ rdsdriver.Configurations = (*Mock)(nil) +) + +const ( + defaultCharset = "UTF8" + defaultCollation = "en_US.utf8" +) + +// knownServerParameters is a representative subset of the PostgreSQL Flexible +// Server parameter catalog mapped to its server default. Azure rejects +// SetConfiguration for a name outside the catalog with 404 (so the mock +// validates against this set rather than accept-and-echo any name), and returns +// the default via Get/List for a known-but-unset parameter rather than 404. +// +//nolint:gochecknoglobals // immutable parameter-name lookup table. +var knownServerParameters = map[string]string{ + "max_connections": "100", + "shared_buffers": "32768", + "work_mem": "4096", + "maintenance_work_mem": "65536", + "effective_cache_size": "524288", + "log_statement": "none", + "log_min_duration_statement": "-1", + "autovacuum": "on", + "statement_timeout": "0", + "timezone": "UTC", + "max_wal_size": "1024", + "wal_level": "replica", + "max_prepared_transactions": "0", + "idle_in_transaction_session_timeout": "0", +} + +func childKey(server, name string) string { return server + "/" + name } + +func (m *Mock) childARN(server, subType, name string) string { + return flexibleServerResourceID(m.opts.Region, server) + "/" + subType + "/" + name +} + +// requireServer returns NotFound when server does not exist. Callers hold the +// lock appropriate to their operation. +func (m *Mock) requireServer(server string) error { + if _, ok := m.instances.Get(server); !ok { + return cerrors.Newf(cerrors.NotFound, "Postgres Flex server %q not found", server) + } + + return nil +} + +// ---- Databases ---- + +// CreateDatabase adds a logical database to a server. +func (m *Mock) CreateDatabase(_ context.Context, cfg rdsdriver.DatabaseConfig) (*rdsdriver.Database, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "database name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + key := childKey(cfg.Server, cfg.Name) + if _, ok := m.databases.Get(key); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "database %q already exists", cfg.Name) + } + + charset := cfg.Charset + if charset == "" { + charset = defaultCharset + } + + collation := cfg.Collation + if collation == "" { + collation = defaultCollation + } + + db := rdsdriver.Database{ + Server: cfg.Server, + Name: cfg.Name, + Charset: charset, + Collation: collation, + ARN: m.childARN(cfg.Server, "databases", cfg.Name), + } + + m.databases.Set(key, db) + + out := db + + return &out, nil +} + +// GetDatabase returns a single logical database. +func (m *Mock) GetDatabase(_ context.Context, server, name string) (*rdsdriver.Database, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + db, ok := m.databases.Get(childKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "database %q not found", name) + } + + out := db + + return &out, nil +} + +// ListDatabases returns all logical databases in a server. +func (m *Mock) ListDatabases(_ context.Context, server string) ([]rdsdriver.Database, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.Database{} + + for _, db := range m.databases.SortedValues() { + if db.Server == server { + out = append(out, db) + } + } + + return out, nil +} + +// DeleteDatabase removes a logical database. +func (m *Mock) DeleteDatabase(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.databases.Delete(childKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "database %q not found", name) + } + + return nil +} + +// ---- Firewall rules ---- + +// CreateFirewallRule creates or replaces a server firewall rule. +func (m *Mock) CreateFirewallRule( + _ context.Context, cfg rdsdriver.FirewallRuleConfig, +) (*rdsdriver.FirewallRule, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "firewall rule name is required") + } + + if !validIPv4(cfg.StartIPAddress) || !validIPv4(cfg.EndIPAddress) { + return nil, cerrors.New(cerrors.InvalidArgument, "startIpAddress and endIpAddress must be valid IPv4 addresses") + } + + if !ipv4LessOrEqual(cfg.StartIPAddress, cfg.EndIPAddress) { + return nil, cerrors.New(cerrors.InvalidArgument, "startIpAddress must be less than or equal to endIpAddress") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + rule := rdsdriver.FirewallRule{ + Server: cfg.Server, + Name: cfg.Name, + StartIPAddress: cfg.StartIPAddress, + EndIPAddress: cfg.EndIPAddress, + ARN: m.childARN(cfg.Server, "firewallRules", cfg.Name), + } + + m.firewallRules.Set(childKey(cfg.Server, cfg.Name), rule) + + out := rule + + return &out, nil +} + +// GetFirewallRule returns a single firewall rule. +func (m *Mock) GetFirewallRule(_ context.Context, server, name string) (*rdsdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rule, ok := m.firewallRules.Get(childKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + out := rule + + return &out, nil +} + +// ListFirewallRules returns all firewall rules on a server. +func (m *Mock) ListFirewallRules(_ context.Context, server string) ([]rdsdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + out := []rdsdriver.FirewallRule{} + + for _, rule := range m.firewallRules.SortedValues() { + if rule.Server == server { + out = append(out, rule) + } + } + + return out, nil +} + +// DeleteFirewallRule removes a firewall rule. +func (m *Mock) DeleteFirewallRule(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.firewallRules.Delete(childKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + return nil +} + +// ---- Configurations (server parameters) ---- + +// SetConfiguration sets a server parameter value, recording it as a user +// override. +func (m *Mock) SetConfiguration( + _ context.Context, cfg rdsdriver.ConfigurationConfig, +) (*rdsdriver.Configuration, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "configuration name is required") + } + + if _, known := knownServerParameters[cfg.Name]; !known { + return nil, cerrors.Newf(cerrors.NotFound, "unknown server parameter %q", cfg.Name) + } + + if cfg.Value == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "configuration value is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireServer(cfg.Server); err != nil { + return nil, err + } + + key := childKey(cfg.Server, cfg.Name) + + conf, ok := m.configurations.Get(key) + if !ok { + conf = rdsdriver.Configuration{ + Server: cfg.Server, + Name: cfg.Name, + DataType: "String", + ARN: m.childARN(cfg.Server, "configurations", cfg.Name), + } + } + + conf.Value = cfg.Value + conf.Source = "user-override" + + m.configurations.Set(key, conf) + + out := conf + + return &out, nil +} + +// GetConfiguration returns a server parameter — the user override if one was +// set, otherwise the catalog default for a known parameter (real Azure returns +// the system default for an unset-but-valid parameter). Unknown parameters 404. +func (m *Mock) GetConfiguration(_ context.Context, server, name string) (*rdsdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if conf, ok := m.configurations.Get(childKey(server, name)); ok { + out := conf + + return &out, nil + } + + def, known := knownServerParameters[name] + if !known { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + return m.defaultConfiguration(server, name, def), nil +} + +// defaultConfiguration builds the system-default view of a known parameter. +func (m *Mock) defaultConfiguration(server, name, value string) *rdsdriver.Configuration { + return &rdsdriver.Configuration{ + Server: server, + Name: name, + Value: value, + Source: "system-default", + DataType: "String", + ARN: m.childARN(server, "configurations", name), + } +} + +// ListConfigurations returns the full parameter catalog, with user overrides +// applied where present (real Azure lists the catalog with defaults, not just +// the parameters that have been written). +func (m *Mock) ListConfigurations(_ context.Context, server string) ([]rdsdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireServer(server); err != nil { + return nil, err + } + + overrides := make(map[string]rdsdriver.Configuration) + + confs := m.configurations.SortedValues() + for i := range confs { + if confs[i].Server == server { + overrides[confs[i].Name] = confs[i] + } + } + + names := make([]string, 0, len(knownServerParameters)) + for name := range knownServerParameters { + names = append(names, name) + } + + sort.Strings(names) + + out := make([]rdsdriver.Configuration, 0, len(names)) + + for _, name := range names { + if conf, ok := overrides[name]; ok { + out = append(out, conf) + continue + } + + out = append(out, *m.defaultConfiguration(server, name, knownServerParameters[name])) + } + + return out, nil +} diff --git a/providers/gcp/cloudsql/cloudsql.go b/providers/gcp/cloudsql/cloudsql.go index 703efbdb..10ae5500 100644 --- a/providers/gcp/cloudsql/cloudsql.go +++ b/providers/gcp/cloudsql/cloudsql.go @@ -11,6 +11,7 @@ package cloudsql import ( "context" "fmt" + "strings" "sync" "github.com/stackshy/cloudemu/v2/config" @@ -35,6 +36,8 @@ const ( var _ rdsdriver.RelationalDB = (*Mock)(nil) +var _ rdsdriver.BackupRestorer = (*Mock)(nil) + // Mock is the in-memory GCP Cloud SQL implementation. type Mock struct { mu sync.RWMutex @@ -42,6 +45,15 @@ type Mock struct { instances *memstore.Store[rdsdriver.Instance] snapshots *memstore.Store[rdsdriver.Snapshot] + // child resources keyed "instance/name" (sslCerts keyed "instance/sha1") + databases *memstore.Store[rdsdriver.Database] + users *memstore.Store[rdsdriver.User] + sslCerts *memstore.Store[rdsdriver.SslCert] + + // backupSeq gives auto-generated backup-run IDs a deterministic, + // collision-free suffix (guarded by mu). + backupSeq int64 + opts *config.Options monitoring mondriver.Monitoring } @@ -51,6 +63,9 @@ func New(opts *config.Options) *Mock { return &Mock{ instances: memstore.New[rdsdriver.Instance](), snapshots: memstore.New[rdsdriver.Snapshot](), + databases: memstore.New[rdsdriver.Database](), + users: memstore.New[rdsdriver.User](), + sslCerts: memstore.New[rdsdriver.SslCert](), opts: opts, } } @@ -113,9 +128,38 @@ func copyTags(src map[string]string) map[string]string { return out } +func cloneStrings(s []string) []string { + if len(s) == 0 { + return nil + } + + return append([]string(nil), s...) +} + +// cloneInstance / cloneSnapshot deep-copy the slice/map fields so a returned +// value never aliases the memstore — a caller mutating its result (or a +// concurrent reader) can't corrupt the store or trigger a concurrent-map +// read/write panic. Callers own the returned copy. +// +//nolint:gocritic // value copy is intentional — the result must not alias the store. +func cloneInstance(inst rdsdriver.Instance) rdsdriver.Instance { + inst.Tags = copyTags(inst.Tags) + inst.VPCSecurityGroups = cloneStrings(inst.VPCSecurityGroups) + inst.ReadReplicaTargets = cloneStrings(inst.ReadReplicaTargets) + + return inst +} + +//nolint:gocritic // value copy is intentional — the result must not alias the store. +func cloneSnapshot(s rdsdriver.Snapshot) rdsdriver.Snapshot { + s.Tags = copyTags(s.Tags) + + return s +} + // CreateInstance creates a new Cloud SQL instance. // -//nolint:gocritic // cfg matches the driver interface signature. +//nolint:gocritic,gocyclo // cfg matches the driver signature; linear field-defaulting plus optional replica linking. func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) (*rdsdriver.Instance, error) { if cfg.ID == "" { return nil, cerrors.New(cerrors.InvalidArgument, "instance name is required") @@ -179,15 +223,36 @@ func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) ( Tags: copyTags(cfg.Tags), } + if cfg.MasterInstanceName != "" { + if err := m.linkReplica(&inst, cfg.MasterInstanceName); err != nil { + return nil, err + } + } + m.instances.Set(cfg.ID, inst) m.emitInstanceMetrics(cfg.ID, cpuMetricRunning, connRunning) - out := inst + out := cloneInstance(inst) return &out, nil } +// linkReplica marks inst as a read replica of masterName and records it on the +// master's replica list. The caller holds the write lock. +func (m *Mock) linkReplica(inst *rdsdriver.Instance, masterName string) error { + master, ok := m.instances.Get(masterName) + if !ok { + return cerrors.Newf(cerrors.NotFound, "master instance %q not found", masterName) + } + + inst.ReadReplicaSource = masterName + master.ReadReplicaTargets = append(append([]string(nil), master.ReadReplicaTargets...), inst.ID) + m.instances.Set(masterName, master) + + return nil +} + // DescribeInstances returns all instances if ids is empty, else only matching ones. func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.Instance, error) { m.mu.RLock() @@ -199,7 +264,7 @@ func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.I //nolint:gocritic // map values are large structs; copy is unavoidable when materializing the result slice. for _, v := range all { - out = append(out, v) + out = append(out, cloneInstance(v)) } return out, nil @@ -213,13 +278,15 @@ func (m *Mock) DescribeInstances(_ context.Context, ids []string) ([]rdsdriver.I return nil, cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) } - out = append(out, inst) + out = append(out, cloneInstance(inst)) } return out, nil } // ModifyInstance applies the supplied changes. +// +//nolint:gocritic // input matches the driver interface signature. func (m *Mock) ModifyInstance( _ context.Context, id string, input rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Instance, error) { @@ -253,23 +320,74 @@ func (m *Mock) ModifyInstance( m.instances.Set(id, inst) - out := inst + out := cloneInstance(inst) return &out, nil } -// DeleteInstance removes an instance. +// DeleteInstance removes an instance, unlinks it from any replica relationship, +// and cascades to its children. func (m *Mock) DeleteInstance(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() - if !m.instances.Delete(id) { + inst, ok := m.instances.Get(id) + if !ok { return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) } + m.instances.Delete(id) + m.unlinkReplicas(&inst) + m.deleteChildren(id) + return nil } +// unlinkReplicas keeps the replica graph consistent when inst is removed: a +// deleted replica is dropped from its master's target list, and a deleted +// master's replicas have their source pointer cleared — so no surviving +// instance advertises a link to one that no longer exists. The caller holds the +// write lock. +func (m *Mock) unlinkReplicas(inst *rdsdriver.Instance) { + if inst.ReadReplicaSource != "" { + if master, ok := m.instances.Get(inst.ReadReplicaSource); ok { + master.ReadReplicaTargets = removeStr(master.ReadReplicaTargets, inst.ID) + m.instances.Set(inst.ReadReplicaSource, master) + } + } + + for _, replicaID := range inst.ReadReplicaTargets { + if replica, ok := m.instances.Get(replicaID); ok { + replica.ReadReplicaSource = "" + m.instances.Set(replicaID, replica) + } + } +} + +// deleteChildren removes the databases, users and SSL certs belonging to +// instance id. The caller already holds the write lock. +func (m *Mock) deleteChildren(instance string) { + prefix := instance + "/" + + for key := range m.databases.All() { + if strings.HasPrefix(key, prefix) { + m.databases.Delete(key) + } + } + + for key := range m.users.All() { + if strings.HasPrefix(key, prefix) { + m.users.Delete(key) + } + } + + for key := range m.sslCerts.All() { + if strings.HasPrefix(key, prefix) { + m.sslCerts.Delete(key) + } + } +} + // StartInstance moves a stopped instance back to runnable. In Cloud SQL this // corresponds to setting settings.activationPolicy=ALWAYS. func (m *Mock) StartInstance(_ context.Context, id string) error { @@ -345,6 +463,8 @@ func (*Mock) DescribeClusters(_ context.Context, _ []string) ([]rdsdriver.Cluste } // ModifyCluster is unsupported on Cloud SQL. +// +//nolint:gocritic // input matches the driver interface signature. func (*Mock) ModifyCluster( _ context.Context, _ string, _ rdsdriver.ModifyInstanceInput, ) (*rdsdriver.Cluster, error) { @@ -369,10 +489,6 @@ func (*Mock) StopCluster(_ context.Context, _ string) error { // CreateSnapshot creates a backup run for an instance. Cloud SQL calls // these "backup runs"; the portable API exposes them as snapshots. func (m *Mock) CreateSnapshot(_ context.Context, cfg rdsdriver.SnapshotConfig) (*rdsdriver.Snapshot, error) { - if cfg.ID == "" { - return nil, cerrors.New(cerrors.InvalidArgument, "snapshot id is required") - } - m.mu.Lock() defer m.mu.Unlock() @@ -381,13 +497,22 @@ func (m *Mock) CreateSnapshot(_ context.Context, cfg rdsdriver.SnapshotConfig) ( return nil, cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", cfg.InstanceID) } - if _, ok := m.snapshots.Get(cfg.ID); ok { - return nil, cerrors.Newf(cerrors.AlreadyExists, "backup run %q already exists", cfg.ID) + // Cloud SQL generates the backup-run ID server-side; derive one + // deterministically from the clock and a monotonic counter when the caller + // omits it, so tests stay reproducible under a fake clock. + id := cfg.ID + if id == "" { + m.backupSeq++ + id = fmt.Sprintf("%d", m.opts.Clock.Now().UnixNano()+m.backupSeq) + } + + if _, ok := m.snapshots.Get(id); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "backup run %q already exists", id) } snap := rdsdriver.Snapshot{ - ID: cfg.ID, - ARN: idgen.GCPID(m.opts.ProjectID, "instances/"+cfg.InstanceID+"/backupRuns", cfg.ID), + ID: id, + ARN: idgen.GCPID(m.opts.ProjectID, "instances/"+cfg.InstanceID+"/backupRuns", id), InstanceID: cfg.InstanceID, Engine: inst.Engine, EngineVersion: inst.EngineVersion, @@ -397,9 +522,9 @@ func (m *Mock) CreateSnapshot(_ context.Context, cfg rdsdriver.SnapshotConfig) ( Tags: copyTags(cfg.Tags), } - m.snapshots.Set(cfg.ID, snap) + m.snapshots.Set(id, snap) - out := snap + out := cloneSnapshot(snap) return &out, nil } @@ -428,7 +553,7 @@ func (m *Mock) DescribeSnapshots( } } - out = append(out, snap) + out = append(out, cloneSnapshot(snap)) } return out, nil @@ -493,7 +618,41 @@ func (m *Mock) RestoreInstanceFromSnapshot( m.emitInstanceMetrics(input.NewInstanceID, cpuMetricRunning, connRunning) - out := inst + out := cloneInstance(inst) + + return &out, nil +} + +// RestoreBackup restores a backup run in place onto an existing instance, +// matching Cloud SQL's restoreBackup semantics: the target instance must +// already exist and its engine/version/storage are overwritten from the +// backup. Unlike RestoreInstanceFromSnapshot it never provisions a new +// instance. +func (m *Mock) RestoreBackup( + _ context.Context, targetInstanceID, backupRunID string, +) (*rdsdriver.Instance, error) { + m.mu.Lock() + defer m.mu.Unlock() + + snap, ok := m.snapshots.Get(backupRunID) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "backup run %q not found", backupRunID) + } + + inst, ok := m.instances.Get(targetInstanceID) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", targetInstanceID) + } + + inst.Engine = snap.Engine + inst.EngineVersion = snap.EngineVersion + inst.AllocatedStorage = snap.AllocatedStorage + inst.State = rdsdriver.StateAvailable + + m.instances.Set(targetInstanceID, inst) + m.emitInstanceMetrics(targetInstanceID, cpuMetricRunning, connRunning) + + out := cloneInstance(inst) return &out, nil } diff --git a/providers/gcp/cloudsql/cloudsql_test.go b/providers/gcp/cloudsql/cloudsql_test.go index f7554b4d..bb1674c1 100644 --- a/providers/gcp/cloudsql/cloudsql_test.go +++ b/providers/gcp/cloudsql/cloudsql_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/gcp/cloudmonitoring" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -169,6 +170,49 @@ func TestSnapshotAndRestore(t *testing.T) { requireNoError(t, m.DeleteSnapshot(ctx, "snap1")) } +func TestRestoreBackupInPlace(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "src", + Engine: "POSTGRES_15", + AllocatedStorage: 100, + }) + requireNoError(t, err) + + _, err = m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "target", + Engine: "MYSQL_8_0", + AllocatedStorage: 20, + }) + requireNoError(t, err) + + _, err = m.CreateSnapshot(ctx, rdsdriver.SnapshotConfig{ID: "snap1", InstanceID: "src"}) + requireNoError(t, err) + + // Restoring in place keeps the target's identity but adopts the backup's + // engine/version/storage — and does NOT create a new instance. + restored, err := m.RestoreBackup(ctx, "target", "snap1") + requireNoError(t, err) + assertEqual(t, "target", restored.ID) + assertEqual(t, 100, restored.AllocatedStorage) + assertEqual(t, "POSTGRES_15", restored.Engine) + + instances, err := m.DescribeInstances(ctx, nil) + requireNoError(t, err) + assertEqual(t, 2, len(instances)) + + // Missing target and missing backup both surface NotFound. + if _, err := m.RestoreBackup(ctx, "ghost", "snap1"); err == nil { + t.Error("RestoreBackup onto missing instance: expected NotFound") + } + + if _, err := m.RestoreBackup(ctx, "target", "ghost"); err == nil { + t.Error("RestoreBackup with missing backup: expected NotFound") + } +} + func TestClusterOpsUnsupported(t *testing.T) { m := newTestMock() ctx := context.Background() @@ -230,3 +274,309 @@ func assertNotEmpty(t *testing.T, s string) { t.Error("expected non-empty string") } } + +func TestSubResourcesRequireInstance(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "ghost", Name: "db"}); err == nil { + t.Error("CreateDatabase on missing instance: expected error") + } + + if _, err := m.CreateUser(ctx, rdsdriver.UserConfig{Instance: "ghost", Name: "u"}); err == nil { + t.Error("CreateUser on missing instance: expected error") + } + + if _, err := m.CreateSslCert(ctx, rdsdriver.SslCertConfig{Instance: "ghost", CommonName: "c"}); err == nil { + t.Error("CreateSslCert on missing instance: expected error") + } +} + +func TestCloneAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "src", Engine: "POSTGRES_15"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "src", Name: "app"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if _, err := m.CreateUser(ctx, rdsdriver.UserConfig{Instance: "src", Name: "u"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + clone, err := m.CloneInstance(ctx, "src", "dst") + if err != nil { + t.Fatalf("CloneInstance: %v", err) + } + + if clone.ID != "dst" || clone.Engine != "POSTGRES_15" { + t.Errorf("clone: got id=%q engine=%q", clone.ID, clone.Engine) + } + + if _, err := m.CloneInstance(ctx, "src", "dst"); err == nil { + t.Error("clone onto existing instance: expected AlreadyExists") + } + + // Deleting the source cascades to its children but leaves the clone. + if err := m.DeleteInstance(ctx, "src"); err != nil { + t.Fatalf("DeleteInstance: %v", err) + } + + if _, err := m.ListDatabases(ctx, "src"); err == nil { + t.Error("ListDatabases after instance delete: expected NotFound") + } + + if _, err := m.DescribeInstances(ctx, []string{"dst"}); err != nil { + t.Errorf("clone should survive source delete: %v", err) + } +} + +func TestCloudSQLReplicaAndFailoverActions(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "i", Engine: "POSTGRES_15"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Failover is valid on a primary, not on a replica. + if err := m.FailoverInstance(ctx, "i"); err != nil { + t.Errorf("FailoverInstance: %v", err) + } + + // Promote requires an actual replica. + if err := m.PromoteReplica(ctx, "i"); err == nil { + t.Error("PromoteReplica on a non-replica: expected FailedPrecondition") + } + + // Create a replica of i, then promote it — it detaches and the primary + // loses it from its replica list. + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "r", Engine: "POSTGRES_15", MasterInstanceName: "i", + }); err != nil { + t.Fatalf("CreateInstance replica: %v", err) + } + + if err := m.FailoverInstance(ctx, "r"); err == nil { + t.Error("FailoverInstance on a replica: expected FailedPrecondition") + } + + if err := m.PromoteReplica(ctx, "r"); err != nil { + t.Fatalf("PromoteReplica: %v", err) + } + + got, _ := m.DescribeInstances(ctx, []string{"r"}) + if got[0].ReadReplicaSource != "" { + t.Errorf("promoted replica still has master %q", got[0].ReadReplicaSource) + } + + primary, _ := m.DescribeInstances(ctx, []string{"i"}) + if len(primary[0].ReadReplicaTargets) != 0 { + t.Errorf("primary still lists promoted replica: %v", primary[0].ReadReplicaTargets) + } + + if err := m.FailoverInstance(ctx, "ghost"); err == nil { + t.Error("FailoverInstance on missing instance: expected NotFound") + } +} + +func TestDeleteInstanceUnlinksReplicas(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + mk := func(id, master string) { + _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: id, Engine: "POSTGRES_15", MasterInstanceName: master, + }) + if err != nil { + t.Fatalf("CreateInstance %q: %v", id, err) + } + } + + mk("master", "") + mk("replica", "master") + + // Deleting the replica drops it from the master's target list. + if err := m.DeleteInstance(ctx, "replica"); err != nil { + t.Fatalf("DeleteInstance replica: %v", err) + } + + got, _ := m.DescribeInstances(ctx, []string{"master"}) + if len(got[0].ReadReplicaTargets) != 0 { + t.Errorf("master still lists a deleted replica: %v", got[0].ReadReplicaTargets) + } + + // Deleting the master clears the source pointer on its surviving replica. + mk("replica2", "master") + + if err := m.DeleteInstance(ctx, "master"); err != nil { + t.Fatalf("DeleteInstance master: %v", err) + } + + got, _ = m.DescribeInstances(ctx, []string{"replica2"}) + if got[0].ReadReplicaSource != "" { + t.Errorf("replica still points at a deleted master: %q", got[0].ReadReplicaSource) + } +} + +func TestSubResourceCRUDCoverage(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "inst", Engine: "MYSQL_8_0"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Databases. + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "inst", Name: "app"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if got, err := m.GetDatabase(ctx, "inst", "app"); err != nil || got.Name != "app" { + t.Fatalf("GetDatabase: %+v %v", got, err) + } + + if err := m.DeleteDatabase(ctx, "inst", "app"); err != nil { + t.Fatalf("DeleteDatabase: %v", err) + } + + // Users: create, get, list, update, delete. + if _, err := m.CreateUser(ctx, rdsdriver.UserConfig{Instance: "inst", Name: "u1", Host: "%", Password: "p1"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if got, err := m.GetUser(ctx, "inst", "u1"); err != nil || got.Name != "u1" { + t.Fatalf("GetUser: %+v %v", got, err) + } + + if us, err := m.ListUsers(ctx, "inst"); err != nil || len(us) != 1 { + t.Fatalf("ListUsers: %d %v", len(us), err) + } + + if _, err := m.UpdateUser(ctx, rdsdriver.UserConfig{Instance: "inst", Name: "u1", Host: "%", Password: "p2"}); err != nil { + t.Fatalf("UpdateUser: %v", err) + } + + if _, err := m.UpdateUser(ctx, rdsdriver.UserConfig{Instance: "inst", Name: "ghost", Host: "%"}); err == nil { + t.Error("UpdateUser on missing user: expected NotFound") + } + + if err := m.DeleteUser(ctx, "inst", "u1"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + + // SSL certs: create, get, list, delete. + cert, err := m.CreateSslCert(ctx, rdsdriver.SslCertConfig{Instance: "inst", CommonName: "client"}) + if err != nil { + t.Fatalf("CreateSslCert: %v", err) + } + + if got, err := m.GetSslCert(ctx, "inst", cert.Sha1Fingerprint); err != nil || got.CommonName != "client" { + t.Fatalf("GetSslCert: %+v %v", got, err) + } + + if cs, err := m.ListSslCerts(ctx, "inst"); err != nil || len(cs) != 1 { + t.Fatalf("ListSslCerts: %d %v", len(cs), err) + } + + if err := m.DeleteSslCert(ctx, "inst", cert.Sha1Fingerprint); err != nil { + t.Fatalf("DeleteSslCert: %v", err) + } + + // Snapshot describe + delete-snapshot NotFound. + if _, err := m.CreateSnapshot(ctx, rdsdriver.SnapshotConfig{ID: "b1", InstanceID: "inst"}); err != nil { + t.Fatalf("CreateSnapshot: %v", err) + } + + if snaps, err := m.DescribeSnapshots(ctx, nil, "inst"); err != nil || len(snaps) != 1 { + t.Fatalf("DescribeSnapshots: %d %v", len(snaps), err) + } +} + +func TestClusterOpsAndMonitoringCoverage(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("us-central1"), config.WithProjectID("p")) + + m := New(opts) + mon := cloudmonitoring.New(opts) + m.SetMonitoring(mon) + + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "inst", Engine: "MYSQL_8_0"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // Cluster and cluster-snapshot ops are unsupported on Cloud SQL. + if _, err := m.ModifyCluster(ctx, "x", rdsdriver.ModifyInstanceInput{}); err == nil { + t.Error("ModifyCluster: expected unsupported") + } + + if err := m.DeleteCluster(ctx, "x"); err == nil { + t.Error("DeleteCluster: expected unsupported") + } + + if err := m.StopCluster(ctx, "x"); err == nil { + t.Error("StopCluster: expected unsupported") + } + + if err := m.DeleteClusterSnapshot(ctx, "x"); err == nil { + t.Error("DeleteClusterSnapshot: expected unsupported") + } + + if _, err := m.RestoreClusterFromSnapshot(ctx, rdsdriver.RestoreClusterInput{}); err == nil { + t.Error("RestoreClusterFromSnapshot: expected unsupported") + } +} + +func TestDescribeInstancesResultDoesNotAliasStore(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "inst", Engine: "MYSQL_8_0", Tags: map[string]string{"env": "prod"}, + }); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + got, err := m.DescribeInstances(ctx, []string{"inst"}) + requireNoError(t, err) + + // Mutating the returned Tags map must not corrupt the store. + got[0].Tags["env"] = "tampered" + got[0].Tags["injected"] = "x" + + reread, err := m.DescribeInstances(ctx, []string{"inst"}) + requireNoError(t, err) + + if reread[0].Tags["env"] != "prod" { + t.Errorf("store Tags aliased: got %q, want prod", reread[0].Tags["env"]) + } + + if _, ok := reread[0].Tags["injected"]; ok { + t.Error("store Tags aliased: injected key leaked into store") + } +} + +func TestChildNameRejectsSlash(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateInstance(ctx, rdsdriver.InstanceConfig{ID: "inst", Engine: "MYSQL_8_0"}); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + // A '/' in a child name would collide with the "{instance}/{name}" key and + // create a row unreachable via single-segment GET/DELETE. + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "inst", Name: "a/b"}); err == nil { + t.Error("CreateDatabase with '/' in name: expected InvalidArgument") + } + + if _, err := m.CreateUser(ctx, rdsdriver.UserConfig{Instance: "inst", Name: "a/b"}); err == nil { + t.Error("CreateUser with '/' in name: expected InvalidArgument") + } +} diff --git a/providers/gcp/cloudsql/subresources.go b/providers/gcp/cloudsql/subresources.go new file mode 100644 index 00000000..d55d05cd --- /dev/null +++ b/providers/gcp/cloudsql/subresources.go @@ -0,0 +1,465 @@ +package cloudsql + +import ( + "context" + "crypto/sha1" //nolint:gosec // SHA-1 fingerprints are the Cloud SQL cert identifier format, not a security control. + "encoding/hex" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// Cloud SQL exposes databases, users and SSL certs as instance child resources, +// and supports clone, failover and replica lifecycle actions on instances. +// These are optional relationaldb driver capabilities discovered by the REST +// handler via type assertion. +var ( + _ rdsdriver.Databases = (*Mock)(nil) + _ rdsdriver.Users = (*Mock)(nil) + _ rdsdriver.SslCerts = (*Mock)(nil) + _ rdsdriver.Failover = (*Mock)(nil) + _ rdsdriver.Clonable = (*Mock)(nil) + _ rdsdriver.ReplicaPromotion = (*Mock)(nil) +) + +const ( + defaultCharset = "UTF8" + defaultCollation = "en_US.UTF8" + defaultUserHost = "%" +) + +func childKey(instance, name string) string { return instance + "/" + name } + +// validChildName rejects a child-resource name containing '/', which would +// collide with the "{instance}/{name}" storage key and create a row that is +// unreachable via the single-segment GET/DELETE paths (real Cloud SQL rejects +// such names too). +func validChildName(kind, name string) error { + if strings.Contains(name, "/") { + return cerrors.Newf(cerrors.InvalidArgument, "%s name %q must not contain '/'", kind, name) + } + + return nil +} + +func (m *Mock) requireInstance(instance string) error { + if _, ok := m.instances.Get(instance); !ok { + return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", instance) + } + + return nil +} + +// ---- Databases ---- + +// CreateDatabase adds a logical database to an instance. +func (m *Mock) CreateDatabase(_ context.Context, cfg rdsdriver.DatabaseConfig) (*rdsdriver.Database, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "database name is required") + } + + if err := validChildName("database", cfg.Name); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireInstance(cfg.Server); err != nil { + return nil, err + } + + key := childKey(cfg.Server, cfg.Name) + if _, ok := m.databases.Get(key); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "database %q already exists", cfg.Name) + } + + charset := cfg.Charset + if charset == "" { + charset = defaultCharset + } + + collation := cfg.Collation + if collation == "" { + collation = defaultCollation + } + + db := rdsdriver.Database{ + Server: cfg.Server, + Name: cfg.Name, + Charset: charset, + Collation: collation, + ARN: idgen.GCPID(m.opts.ProjectID, "instances/"+cfg.Server+"/databases", cfg.Name), + } + + m.databases.Set(key, db) + + out := db + + return &out, nil +} + +// GetDatabase returns a single logical database. +func (m *Mock) GetDatabase(_ context.Context, instance, name string) (*rdsdriver.Database, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + db, ok := m.databases.Get(childKey(instance, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "database %q not found", name) + } + + out := db + + return &out, nil +} + +// ListDatabases returns all logical databases in an instance. +func (m *Mock) ListDatabases(_ context.Context, instance string) ([]rdsdriver.Database, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireInstance(instance); err != nil { + return nil, err + } + + out := []rdsdriver.Database{} + + for _, db := range m.databases.SortedValues() { + if db.Server == instance { + out = append(out, db) + } + } + + return out, nil +} + +// DeleteDatabase removes a logical database. +func (m *Mock) DeleteDatabase(_ context.Context, instance, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.databases.Delete(childKey(instance, name)) { + return cerrors.Newf(cerrors.NotFound, "database %q not found", name) + } + + return nil +} + +// ---- Users ---- + +// CreateUser adds a database user to an instance. +func (m *Mock) CreateUser(_ context.Context, cfg rdsdriver.UserConfig) (*rdsdriver.User, error) { + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "user name is required") + } + + if err := validChildName("user", cfg.Name); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireInstance(cfg.Instance); err != nil { + return nil, err + } + + key := childKey(cfg.Instance, cfg.Name) + if _, ok := m.users.Get(key); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "user %q already exists", cfg.Name) + } + + host := cfg.Host + if host == "" { + host = defaultUserHost + } + + user := rdsdriver.User{Instance: cfg.Instance, Name: cfg.Name, Host: host} + m.users.Set(key, user) + + out := user + + return &out, nil +} + +// GetUser returns a single database user. +func (m *Mock) GetUser(_ context.Context, instance, name string) (*rdsdriver.User, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + user, ok := m.users.Get(childKey(instance, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "user %q not found", name) + } + + out := user + + return &out, nil +} + +// ListUsers returns all users in an instance. +func (m *Mock) ListUsers(_ context.Context, instance string) ([]rdsdriver.User, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireInstance(instance); err != nil { + return nil, err + } + + out := []rdsdriver.User{} + + for _, user := range m.users.SortedValues() { + if user.Instance == instance { + out = append(out, user) + } + } + + return out, nil +} + +// UpdateUser updates an existing user's host (the only mutable field the mock +// tracks) and returns NotFound when the user does not exist. +func (m *Mock) UpdateUser(_ context.Context, cfg rdsdriver.UserConfig) (*rdsdriver.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireInstance(cfg.Instance); err != nil { + return nil, err + } + + key := childKey(cfg.Instance, cfg.Name) + + user, ok := m.users.Get(key) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "user %q not found", cfg.Name) + } + + if cfg.Host != "" { + user.Host = cfg.Host + } + + m.users.Set(key, user) + + out := user + + return &out, nil +} + +// DeleteUser removes a database user. +func (m *Mock) DeleteUser(_ context.Context, instance, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.users.Delete(childKey(instance, name)) { + return cerrors.Newf(cerrors.NotFound, "user %q not found", name) + } + + return nil +} + +// ---- SSL certs ---- + +// CreateSslCert issues a client SSL certificate for an instance. The +// fingerprint is derived from the common name so it is stable across calls. +func (m *Mock) CreateSslCert(_ context.Context, cfg rdsdriver.SslCertConfig) (*rdsdriver.SslCert, error) { + if cfg.CommonName == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "commonName is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireInstance(cfg.Instance); err != nil { + return nil, err + } + + sum := sha1.Sum([]byte(cfg.Instance + "/" + cfg.CommonName)) //nolint:gosec // fingerprint, not a security control. + fingerprint := hex.EncodeToString(sum[:]) + + cert := rdsdriver.SslCert{ + Instance: cfg.Instance, + CommonName: cfg.CommonName, + Sha1Fingerprint: fingerprint, + Cert: "-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----", + SerialNumber: fingerprint[:16], + } + + m.sslCerts.Set(childKey(cfg.Instance, fingerprint), cert) + + out := cert + + return &out, nil +} + +// GetSslCert returns a single SSL cert by fingerprint. +func (m *Mock) GetSslCert(_ context.Context, instance, sha1FP string) (*rdsdriver.SslCert, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + cert, ok := m.sslCerts.Get(childKey(instance, sha1FP)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "sslCert %q not found", sha1FP) + } + + out := cert + + return &out, nil +} + +// ListSslCerts returns all SSL certs for an instance. +func (m *Mock) ListSslCerts(_ context.Context, instance string) ([]rdsdriver.SslCert, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireInstance(instance); err != nil { + return nil, err + } + + out := []rdsdriver.SslCert{} + + for _, cert := range m.sslCerts.SortedValues() { + if cert.Instance == instance { + out = append(out, cert) + } + } + + return out, nil +} + +// DeleteSslCert removes an SSL cert by fingerprint. +func (m *Mock) DeleteSslCert(_ context.Context, instance, sha1FP string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.sslCerts.Delete(childKey(instance, sha1FP)) { + return cerrors.Newf(cerrors.NotFound, "sslCert %q not found", sha1FP) + } + + return nil +} + +// ---- Instance actions ---- + +// FailoverInstance promotes the standby of a regional (HA) instance. Failover +// is a primary-instance operation; real Cloud SQL rejects it on a read replica. +func (m *Mock) FailoverInstance(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + inst, ok := m.instances.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) + } + + if inst.ReadReplicaSource != "" { + return cerrors.Newf(cerrors.FailedPrecondition, + "Cloud SQL instance %q is a read replica; failover applies to the primary", id) + } + + m.emitInstanceMetrics(id, cpuMetricRunning, connRunning) + + return nil +} + +// PromoteReplica detaches a read replica from its primary, making it a +// standalone instance. Real Cloud SQL rejects it on a non-replica. +func (m *Mock) PromoteReplica(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + inst, ok := m.instances.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) + } + + if inst.ReadReplicaSource == "" { + return cerrors.Newf(cerrors.FailedPrecondition, + "Cloud SQL instance %q is not a read replica", id) + } + + master, ok := m.instances.Get(inst.ReadReplicaSource) + if ok { + master.ReadReplicaTargets = removeStr(master.ReadReplicaTargets, id) + m.instances.Set(inst.ReadReplicaSource, master) + } + + inst.ReadReplicaSource = "" + m.instances.Set(id, inst) + + return nil +} + +// removeStr returns a new slice with s removed (replace-on-write; never mutates +// the stored slice in place). +func removeStr(items []string, s string) []string { + out := make([]string, 0, len(items)) + + for _, v := range items { + if v != s { + out = append(out, v) + } + } + + return out +} + +// CloneInstance copies sourceID to a new instance named destID. +func (m *Mock) CloneInstance(_ context.Context, sourceID, destID string) (*rdsdriver.Instance, error) { + if destID == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "destinationInstanceName is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + src, ok := m.instances.Get(sourceID) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", sourceID) + } + + if _, ok := m.instances.Get(destID); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "Cloud SQL instance %q already exists", destID) + } + + clone := src + clone.ID = destID + clone.ARN = idgen.GCPID(m.opts.ProjectID, "instances", destID) + clone.Endpoint = instanceConnectionName(m.opts.ProjectID, src.AvailabilityZone, destID) + clone.State = rdsdriver.StateAvailable + clone.CreatedAt = m.opts.Clock.Now().UTC() + // A clone is a standalone primary, not part of the source's replica chain. + clone.ReadReplicaSource = "" + clone.ReadReplicaTargets = nil + + clone.VPCSecurityGroups = append([]string(nil), src.VPCSecurityGroups...) + clone.Tags = copyTags(src.Tags) + + m.instances.Set(destID, clone) + + m.cloneDatabases(sourceID, destID) + + m.emitInstanceMetrics(destID, cpuMetricRunning, connRunning) + + out := cloneInstance(clone) + + return &out, nil +} + +// cloneDatabases copies the source instance's logical databases onto dest. +// The caller holds the write lock. +func (m *Mock) cloneDatabases(sourceID, destID string) { + dbs := m.databases.SortedValues() + for i := range dbs { + if dbs[i].Server != sourceID { + continue + } + + nd := dbs[i] + nd.Server = destID + nd.ARN = idgen.GCPID(m.opts.ProjectID, "instances/"+destID+"/databases", nd.Name) + m.databases.Set(childKey(destID, nd.Name), nd) + } +} diff --git a/providers/gcp/gcp.go b/providers/gcp/gcp.go index baa8a3e5..e7d8a777 100644 --- a/providers/gcp/gcp.go +++ b/providers/gcp/gcp.go @@ -127,14 +127,39 @@ func New(opts ...config.Option) *Provider { p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderGCP, o.ProjectID, o.Region, &resourcediscovery.Drivers{ - Compute: p.GCE, - Networking: p.VPC, - Storage: p.GCS, - Database: p.Firestore, - Serverless: p.CloudFunctions, - Kubernetes: gkeDiscovery{p.GKE}, + Compute: p.GCE, + Networking: p.VPC, + Storage: p.GCS, + Database: p.Firestore, + Serverless: p.CloudFunctions, + Kubernetes: gkeDiscovery{p.GKE}, + RelationalDB: cloudSQLDiscovery{p.CloudSQL}, }, ) return p } + +// cloudSQLDiscovery adapts the Cloud SQL mock to the resourcediscovery +// RelationalDatabases capability, so instances surface in Cloud Asset Inventory. +type cloudSQLDiscovery struct{ m *cloudsql.Mock } + +func (d cloudSQLDiscovery) DiscoverDatabases( + ctx context.Context, +) ([]resourcediscovery.DiscoveredDatabase, error) { + insts, err := d.m.DescribeInstances(ctx, nil) + if err != nil { + return nil, err + } + + out := make([]resourcediscovery.DiscoveredDatabase, 0, len(insts)) + + for i := range insts { + out = append(out, resourcediscovery.DiscoveredDatabase{ + Name: insts[i].ID, Type: resourcediscovery.TypeSQLInstance, + Region: insts[i].AvailabilityZone, ARN: insts[i].ARN, Tags: insts[i].Tags, + }) + } + + return out, nil +} diff --git a/server/azure/azuresql/error_paths_test.go b/server/azure/azuresql/error_paths_test.go new file mode 100644 index 00000000..b0a3f8c5 --- /dev/null +++ b/server/azure/azuresql/error_paths_test.go @@ -0,0 +1,63 @@ +package azuresql_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" +) + +// statusOf extracts the HTTP status from a typed ARM SDK error. +func statusOf(t *testing.T, err error) int { + t.Helper() + + var re *azcore.ResponseError + if !errors.As(err, &re) { + t.Fatalf("expected an azcore.ResponseError, got %T: %v", err, err) + } + + return re.StatusCode +} + +// The provider-level behavior is tested elsewhere; this verifies the ARM wire +// layer maps canonical errors to the right HTTP status (GCP has the equivalent +// TestSDKCloudSQLErrorPaths). +func TestSDKAzureSQLWireErrorMapping(t *testing.T) { + cf := newFactory(t) + ctx := context.Background() + + // 404 — server that doesn't exist. + if _, err := cf.NewServersClient().Get(ctx, "rg-1", "ghost", nil); err == nil { + t.Error("Get missing server: expected error") + } else if got := statusOf(t, err); got != http.StatusNotFound { + t.Errorf("Get missing server: status %d, want 404", got) + } + + // 404 — database on a server that doesn't exist. + if _, err := cf.NewDatabasesClient().Get(ctx, "rg-1", "ghost", "db", nil); err == nil { + t.Error("Get database on missing server: expected error") + } else if got := statusOf(t, err); got != http.StatusNotFound { + t.Errorf("Get database on missing server: status %d, want 404", got) + } + + mustCreateSQLServer(t, cf) + + // 400 — firewall rule with start > end. + fw := cf.NewFirewallRulesClient() + + _, err := fw.CreateOrUpdate(ctx, "rg-1", "srv1", "bad", armsql.FirewallRule{ + Properties: &armsql.ServerFirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.9"), + EndIPAddress: to.Ptr("10.0.0.1"), + }, + }, nil) + if err == nil { + t.Error("firewall rule with start > end: expected error") + } else if got := statusOf(t, err); got != http.StatusBadRequest { + t.Errorf("firewall start > end: status %d, want 400", got) + } +} diff --git a/server/azure/azuresql/handler.go b/server/azure/azuresql/handler.go index 27f207b0..cd10ee45 100644 --- a/server/azure/azuresql/handler.go +++ b/server/azure/azuresql/handler.go @@ -28,9 +28,23 @@ import ( ) const ( - providerName = "Microsoft.Sql" - resourceServers = "servers" - subResourceDatabases = "databases" + providerName = "Microsoft.Sql" + resourceServers = "servers" + resourceManagedInstances = "managedInstances" + subResourceDatabases = "databases" + + subFirewallRules = "firewallRules" + subVNetRules = "virtualNetworkRules" + subElasticPools = "elasticPools" + subFailoverGroups = "failoverGroups" + subAdministrators = "administrators" + + subMIStart = "start" + subMIStop = "stop" + subMIFailover = "failover" + + actionFailover = "failover" + actionForceFailover = "forceFailoverAllowDataLoss" ) // Handler serves Microsoft.Sql ARM requests against a relationaldb driver. @@ -43,14 +57,18 @@ func New(db rdsdriver.RelationalDB) *Handler { return &Handler{db: db} } -// Matches returns true for ARM Microsoft.Sql server/database paths. +// Matches returns true for ARM Microsoft.Sql server and managed-instance 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 == resourceServers + if rp.Provider != providerName { + return false + } + + return rp.ResourceType == resourceServers || rp.ResourceType == resourceManagedInstances } // ServeHTTP routes the request based on path shape and method. @@ -61,9 +79,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Database-scoped: .../servers/{srv}/databases[/{db}] - if rp.SubResource == subResourceDatabases { - h.serveDatabaseRoute(w, r, &rp) + if rp.ResourceType == resourceManagedInstances { + h.serveManagedInstanceRoute(w, r, &rp) + return + } + + // Child resources: .../servers/{srv}/{type}[/{name}]. + if rp.SubResource != "" { + h.serveServerChild(w, r, &rp) return } @@ -76,6 +99,27 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveServer(w, r, &rp) } +// serveServerChild dispatches a .../servers/{srv}/{type}[/{name}] path to the +// matching child-resource handler. +func (h *Handler) serveServerChild(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + switch rp.SubResource { + case subResourceDatabases: + h.serveDatabaseRoute(w, r, rp) + case subFirewallRules: + h.serveFirewallRule(w, r, rp) + case subVNetRules: + h.serveVNetRule(w, r, rp) + case subElasticPools: + h.serveElasticPool(w, r, rp) + case subFailoverGroups: + h.serveFailoverGroup(w, r, rp) + case subAdministrators: + h.serveAADAdmin(w, r, rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported sub-resource: "+rp.SubResource) + } +} + func (h *Handler) serveServer(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { switch r.Method { case http.MethodPut: diff --git a/server/azure/azuresql/managedinstance.go b/server/azure/azuresql/managedinstance.go new file mode 100644 index 00000000..e8c95cce --- /dev/null +++ b/server/azure/azuresql/managedinstance.go @@ -0,0 +1,327 @@ +package azuresql + +import ( + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// ---- ARM JSON shapes ---- + +type armManagedInstance struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + SKU *armSKU `json:"sku,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Properties *armManagedInstanceCfg `json:"properties,omitempty"` +} + +type armManagedInstanceCfg struct { + AdministratorLogin string `json:"administratorLogin,omitempty"` + VCores int `json:"vCores,omitempty"` + StorageSizeInGB int `json:"storageSizeInGB,omitempty"` + LicenseType string `json:"licenseType,omitempty"` + SubnetID string `json:"subnetId,omitempty"` + State string `json:"state,omitempty"` + FullyQualifiedDomainName string `json:"fullyQualifiedDomainName,omitempty"` +} + +type armManagedDatabase struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armManagedDatabaseCfg `json:"properties,omitempty"` +} + +type armManagedDatabaseCfg struct { + Collation string `json:"collation,omitempty"` + Status string `json:"status,omitempty"` +} + +func (h *Handler) managedInstances() (rdsdriver.ManagedInstances, bool) { + c, ok := h.db.(rdsdriver.ManagedInstances) + return c, ok +} + +func managedInstanceID(rp *azurearm.ResourcePath) string { + return azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceManagedInstances, rp.ResourceName) +} + +// serveManagedInstanceRoute dispatches /managedInstances[/{name}[/{sub}]]. +func (h *Handler) serveManagedInstanceRoute(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + mi, ok := h.managedInstances() + if !ok { + writeUnsupported(w, "managedInstances") + return + } + + switch { + case rp.SubResource == subResourceDatabases: + h.serveManagedDatabase(w, r, rp, mi) + case rp.SubResource == subMIStart || rp.SubResource == subMIStop || rp.SubResource == subMIFailover: + h.serveManagedInstanceAction(w, r, rp, mi) + case rp.SubResource != "": + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported sub-resource: "+rp.SubResource) + case rp.ResourceName == "": + h.listManagedInstances(w, r, rp, mi) + default: + h.serveManagedInstance(w, r, rp, mi) + } +} + +func (h *Handler) serveManagedInstance( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + switch r.Method { + case http.MethodPut: + h.putManagedInstance(w, r, rp, mi) + case http.MethodPatch: + h.patchManagedInstance(w, r, rp, mi) + case http.MethodGet: + h.getManagedInstance(w, r, rp, mi) + case http.MethodDelete: + if err := mi.DeleteManagedInstance(r.Context(), rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +func miCfgFromBody(body *armManagedInstance, rp *azurearm.ResourcePath) rdsdriver.ManagedInstanceConfig { + cfg := rdsdriver.ManagedInstanceConfig{Name: rp.ResourceName, 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.AdminLogin = body.Properties.AdministratorLogin + cfg.VCores = body.Properties.VCores + cfg.StorageGB = body.Properties.StorageSizeInGB + cfg.LicenseType = body.Properties.LicenseType + cfg.SubnetID = body.Properties.SubnetID + } + + return cfg +} + +func (*Handler) putManagedInstance( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + var body armManagedInstance + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := miCfgFromBody(&body, rp) + + out, err := mi.CreateManagedInstance(r.Context(), cfg) + if err != nil { + if !cerrors.IsAlreadyExists(err) { + azurearm.WriteCErr(w, err) + return + } + + // Upsert: PUT on an existing managed instance applies the body. + out, err = mi.UpdateManagedInstance(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + } + + azurearm.WriteJSON(w, http.StatusOK, toARMManagedInstance(out, rp)) +} + +func (*Handler) patchManagedInstance( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + var body armManagedInstance + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + out, err := mi.UpdateManagedInstance(r.Context(), miCfgFromBody(&body, rp)) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMManagedInstance(out, rp)) +} + +func (*Handler) getManagedInstance( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + out, err := mi.GetManagedInstance(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMManagedInstance(out, rp)) +} + +func (*Handler) listManagedInstances( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + items, err := mi.ListManagedInstances(r.Context()) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armManagedInstance, 0, len(items)) + for i := range items { + out = append(out, toARMManagedInstance(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armManagedInstance]{Value: out}) +} + +func (h *Handler) serveManagedInstanceAction( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w) + return + } + + var err error + + switch rp.SubResource { + case subMIStart: + err = mi.StartManagedInstance(r.Context(), rp.ResourceName) + case subMIStop: + err = mi.StopManagedInstance(r.Context(), rp.ResourceName) + case subMIFailover: + err = mi.FailoverManagedInstance(r.Context(), rp.ResourceName) + } + + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + h.getManagedInstance(w, r, rp, mi) +} + +func toARMManagedInstance(mi *rdsdriver.ManagedInstance, rp *azurearm.ResourcePath) armManagedInstance { + return armManagedInstance{ + ID: managedInstanceID(rp), + Name: mi.Name, + Type: providerName + "/" + resourceManagedInstances, + Location: mi.Location, + Tags: mi.Tags, + SKU: &armSKU{Name: mi.SKUName, Tier: mi.SKUTier}, + Properties: &armManagedInstanceCfg{ + AdministratorLogin: mi.AdminLogin, + VCores: mi.VCores, + StorageSizeInGB: mi.StorageGB, + LicenseType: mi.LicenseType, + SubnetID: mi.SubnetID, + State: mi.State, + FullyQualifiedDomainName: mi.FQDN, + }, + } +} + +// ---- Managed databases ---- + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveManagedDatabase( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + items, err := mi.ListManagedDatabases(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armManagedDatabase, 0, len(items)) + for i := range items { + out = append(out, toARMManagedDatabase(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armManagedDatabase]{Value: out}) + + return + } + + switch r.Method { + case http.MethodPut: + h.putManagedDatabase(w, r, rp, mi) + case http.MethodGet: + out, err := mi.GetManagedDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMManagedDatabase(out, rp)) + case http.MethodDelete: + if err := mi.DeleteManagedDatabase(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putManagedDatabase( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, +) { + var body armManagedDatabase + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.ManagedDatabaseConfig{Instance: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.Collation = body.Properties.Collation + } + + out, err := mi.CreateManagedDatabase(r.Context(), cfg) + if err != nil { + existing, getErr := mi.GetManagedDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) + if getErr != nil { + azurearm.WriteCErr(w, err) + return + } + + out = existing + } + + azurearm.WriteJSON(w, http.StatusOK, toARMManagedDatabase(out, rp)) +} + +func toARMManagedDatabase(mdb *rdsdriver.ManagedDatabase, rp *azurearm.ResourcePath) armManagedDatabase { + return armManagedDatabase{ + ID: managedInstanceID(rp) + "/databases/" + mdb.Name, + Name: mdb.Name, + Type: providerName + "/" + resourceManagedInstances + "/databases", + Properties: &armManagedDatabaseCfg{Collation: mdb.Collation, Status: mdb.Status}, + } +} diff --git a/server/azure/azuresql/managedinstance_raw_test.go b/server/azure/azuresql/managedinstance_raw_test.go new file mode 100644 index 00000000..29cfd440 --- /dev/null +++ b/server/azure/azuresql/managedinstance_raw_test.go @@ -0,0 +1,52 @@ +package azuresql_test + +import ( + "context" + "net/http" + "strings" + "testing" +) + +// The armsql SDK version vendored here exposes managed-instance failover but +// not start/stop, so those POST action routes are exercised with raw HTTP. +func TestManagedInstanceStartStopRaw(t *testing.T) { + ts := newRawServer(t) + ctx := context.Background() + + const base = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Sql/managedInstances/mi1" + const apiVersion = "?api-version=2021-11-01" + + body := `{"location":"eastus","properties":{"administratorLogin":"miadmin","subnetId":"/subnets/mi","vCores":4}}` + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, ts.URL+base+apiVersion, strings.NewReader(body)) + if err != nil { + t.Fatalf("new PUT request: %v", err) + } + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("PUT managed instance: %v", err) + } + resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + t.Fatalf("PUT managed instance: status %d", resp.StatusCode) + } + + for _, action := range []string{"/stop", "/start"} { + areq, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+base+action+apiVersion, nil) + if err != nil { + t.Fatalf("new POST %s: %v", action, err) + } + + aresp, err := ts.Client().Do(areq) + if err != nil { + t.Fatalf("POST %s: %v", action, err) + } + aresp.Body.Close() + + if aresp.StatusCode >= http.StatusBadRequest { + t.Fatalf("POST %s: status %d", action, aresp.StatusCode) + } + } +} diff --git a/server/azure/azuresql/operations.go b/server/azure/azuresql/operations.go index 2d314757..b049ea8d 100644 --- a/server/azure/azuresql/operations.go +++ b/server/azure/azuresql/operations.go @@ -3,6 +3,7 @@ package azuresql import ( "net/http" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/azurearm" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -28,14 +29,21 @@ func (h *Handler) createOrUpdateServer(w http.ResponseWriter, r *http.Request, r cluster, err := h.db.CreateCluster(r.Context(), cfg) if err != nil { - // Idempotent PUT: if the server already exists, treat as a get. - existing, getErr := h.db.DescribeClusters(r.Context(), []string{rp.ResourceName}) - if getErr != nil || len(existing) != 1 { + if !cerrors.IsAlreadyExists(err) { azurearm.WriteCErr(w, err) return } - cluster = &existing[0] + // Upsert: PUT on an existing server applies the body (admin/version/tags) + // rather than returning the stale record. + cluster, err = h.db.ModifyCluster(r.Context(), rp.ResourceName, rdsdriver.ModifyInstanceInput{ + EngineVersion: cfg.EngineVersion, + Tags: body.Tags, + }) + if err != nil { + azurearm.WriteCErr(w, err) + return + } } azurearm.WriteJSON(w, http.StatusOK, toARMServer(cluster, rp.Subscription, rp.ResourceGroup)) @@ -68,6 +76,11 @@ func (h *Handler) getServer(w http.ResponseWriter, r *http.Request, rp *azurearm return } + if len(clusters) == 0 { + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "server "+rp.ResourceName+" not found") + return + } + azurearm.WriteJSON(w, http.StatusOK, toARMServer(&clusters[0], rp.Subscription, rp.ResourceGroup)) } @@ -141,20 +154,32 @@ func (h *Handler) createOrUpdateDatabase(w http.ResponseWriter, r *http.Request, cfg.InstanceClass = body.SKU.Name } - if body.Properties != nil && body.Properties.MaxSizeBytes > 0 { - cfg.AllocatedStorage = int(body.Properties.MaxSizeBytes / (1 << 30)) + if body.Properties != nil { + if body.Properties.MaxSizeBytes > 0 { + cfg.AllocatedStorage = int(body.Properties.MaxSizeBytes / (1 << 30)) + } + + cfg.ElasticPoolID = body.Properties.ElasticPoolID } inst, err := h.db.CreateInstance(r.Context(), cfg) if err != nil { - // Idempotent PUT: if the database already exists, fall back to a get. - existing, getErr := h.db.DescribeInstances(r.Context(), []string{server + "/" + dbName}) - if getErr != nil || len(existing) != 1 { + if !cerrors.IsAlreadyExists(err) { azurearm.WriteCErr(w, err) return } - inst = &existing[0] + // Upsert: PUT on an existing database applies the body (SKU/maxSize/tags). + inst, err = h.db.ModifyInstance(r.Context(), server+"/"+dbName, rdsdriver.ModifyInstanceInput{ + InstanceClass: cfg.InstanceClass, + AllocatedStorage: cfg.AllocatedStorage, + ElasticPoolID: cfg.ElasticPoolID, + Tags: body.Tags, + }) + if err != nil { + azurearm.WriteCErr(w, err) + return + } } azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(inst, rp.Subscription, rp.ResourceGroup)) @@ -174,8 +199,12 @@ func (h *Handler) updateDatabase(w http.ResponseWriter, r *http.Request, rp *azu input.InstanceClass = body.SKU.Name } - if body.Properties != nil && body.Properties.MaxSizeBytes > 0 { - input.AllocatedStorage = int(body.Properties.MaxSizeBytes / (1 << 30)) + if body.Properties != nil { + if body.Properties.MaxSizeBytes > 0 { + input.AllocatedStorage = int(body.Properties.MaxSizeBytes / (1 << 30)) + } + + input.ElasticPoolID = body.Properties.ElasticPoolID } inst, err := h.db.ModifyInstance(r.Context(), rp.ResourceName+"/"+rp.SubResourceName, input) @@ -194,6 +223,11 @@ func (h *Handler) getDatabase(w http.ResponseWriter, r *http.Request, rp *azurea return } + if len(insts) == 0 { + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "database "+rp.SubResourceName+" not found") + return + } + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(&insts[0], rp.Subscription, rp.ResourceGroup)) } diff --git a/server/azure/azuresql/sdk_roundtrip_test.go b/server/azure/azuresql/sdk_roundtrip_test.go index 3fcc236a..867f308c 100644 --- a/server/azure/azuresql/sdk_roundtrip_test.go +++ b/server/azure/azuresql/sdk_roundtrip_test.go @@ -23,7 +23,19 @@ func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcor return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil } -func newSDKClients(t *testing.T) (*armsql.ServersClient, *armsql.DatabasesClient) { +func newRawServer(t *testing.T) *httptest.Server { + t.Helper() + + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{SQL: cloudP.SQL}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + return ts +} + +func newFactory(t *testing.T) *armsql.ClientFactory { t.Helper() cloudP := cloudemu.NewAzure() @@ -55,6 +67,14 @@ func newSDKClients(t *testing.T) (*armsql.ServersClient, *armsql.DatabasesClient t.Fatal(err) } + return cf +} + +func newSDKClients(t *testing.T) (*armsql.ServersClient, *armsql.DatabasesClient) { + t.Helper() + + cf := newFactory(t) + return cf.NewServersClient(), cf.NewDatabasesClient() } @@ -106,6 +126,18 @@ func TestSDKAzureSQLServerLifecycle(t *testing.T) { t.Fatalf("got %d servers, want 1", len(page.Value)) } + // PATCH the server (ServersClient.BeginUpdate → updateServer handler). + upPoller, err := servers.BeginUpdate(ctx, "rg-1", "srv1", armsql.ServerUpdate{ + Properties: &armsql.ServerProperties{Version: to.Ptr("12.1")}, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + if _, err := upPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("update PollUntilDone: %v", err) + } + delPoller, err := servers.BeginDelete(ctx, "rg-1", "srv1", nil) if err != nil { t.Fatalf("BeginDelete: %v", err) diff --git a/server/azure/azuresql/subresources.go b/server/azure/azuresql/subresources.go new file mode 100644 index 00000000..9cc731c9 --- /dev/null +++ b/server/azure/azuresql/subresources.go @@ -0,0 +1,701 @@ +package azuresql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// ---- capability accessors ---- + +func (h *Handler) firewallRules() (rdsdriver.FirewallRules, bool) { + c, ok := h.db.(rdsdriver.FirewallRules) + return c, ok +} + +func (h *Handler) vnetRules() (rdsdriver.VNetRules, bool) { + c, ok := h.db.(rdsdriver.VNetRules) + return c, ok +} + +func (h *Handler) elasticPools() (rdsdriver.ElasticPools, bool) { + c, ok := h.db.(rdsdriver.ElasticPools) + return c, ok +} + +func (h *Handler) failoverGroups() (rdsdriver.FailoverGroups, bool) { + c, ok := h.db.(rdsdriver.FailoverGroups) + return c, ok +} + +func (h *Handler) aadAdmins() (rdsdriver.AADAdmins, bool) { + c, ok := h.db.(rdsdriver.AADAdmins) + return c, ok +} + +func writeUnsupported(w http.ResponseWriter, what string) { + azurearm.WriteError(w, http.StatusBadRequest, "OperationNotSupported", what+" is not supported by this driver") +} + +func childID(rp *azurearm.ResourcePath, subType, name string) string { + return armServerID(rp.Subscription, rp.ResourceGroup, rp.ResourceName) + "/" + subType + "/" + name +} + +// ---- Firewall rules ---- + +type armFirewallRule struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armFirewallRuleCfg `json:"properties,omitempty"` +} + +type armFirewallRuleCfg struct { + StartIPAddress string `json:"startIpAddress,omitempty"` + EndIPAddress string `json:"endIpAddress,omitempty"` +} + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveFirewallRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + fw, ok := h.firewallRules() + if !ok { + writeUnsupported(w, "firewallRules") + return + } + + if rp.SubResourceName == "" { + h.getOrListFirewall(w, r, rp, fw, true) + return + } + + switch r.Method { + case http.MethodPut: + h.putFirewallRule(w, r, rp, fw) + case http.MethodGet: + h.getOrListFirewall(w, r, rp, fw, false) + case http.MethodDelete: + if err := fw.DeleteFirewallRule(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + var body armFirewallRule + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.FirewallRuleConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.StartIPAddress = body.Properties.StartIPAddress + cfg.EndIPAddress = body.Properties.EndIPAddress + } + + out, err := fw.CreateFirewallRule(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(out, rp)) +} + +//nolint:dupl // mirrors the sibling get/list handler by design. +func (*Handler) getOrListFirewall( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, list bool, +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + if !list { + out, err := fw.GetFirewallRule(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(out, rp)) + + return + } + + items, err := fw.ListFirewallRules(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armFirewallRule, 0, len(items)) + for i := range items { + out = append(out, toARMFirewallRule(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armFirewallRule]{Value: out}) +} + +func toARMFirewallRule(fw *rdsdriver.FirewallRule, rp *azurearm.ResourcePath) armFirewallRule { + return armFirewallRule{ + ID: childID(rp, subFirewallRules, fw.Name), + Name: fw.Name, + Type: providerName + "/" + resourceServers + "/" + subFirewallRules, + Properties: &armFirewallRuleCfg{StartIPAddress: fw.StartIPAddress, EndIPAddress: fw.EndIPAddress}, + } +} + +// ---- Virtual network rules ---- + +type armVNetRule struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armVNetRuleCfg `json:"properties,omitempty"` +} + +type armVNetRuleCfg struct { + VirtualNetworkSubnetID string `json:"virtualNetworkSubnetId,omitempty"` + IgnoreMissingVnetServiceEndpoint bool `json:"ignoreMissingVnetServiceEndpoint,omitempty"` + State string `json:"state,omitempty"` +} + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveVNetRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + vr, ok := h.vnetRules() + if !ok { + writeUnsupported(w, "virtualNetworkRules") + return + } + + if rp.SubResourceName == "" { + h.getOrListVNet(w, r, rp, vr, true) + return + } + + switch r.Method { + case http.MethodPut: + h.putVNetRule(w, r, rp, vr) + case http.MethodGet: + h.getOrListVNet(w, r, rp, vr, false) + case http.MethodDelete: + if err := vr.DeleteVNetRule(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putVNetRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, vr rdsdriver.VNetRules, +) { + var body armVNetRule + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.VNetRuleConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.SubnetID = body.Properties.VirtualNetworkSubnetID + cfg.IgnoreMissingEndpoint = body.Properties.IgnoreMissingVnetServiceEndpoint + } + + out, err := vr.CreateVNetRule(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMVNetRule(out, rp)) +} + +//nolint:dupl // mirrors the sibling get/list handler by design. +func (*Handler) getOrListVNet( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, vr rdsdriver.VNetRules, list bool, +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + if !list { + out, err := vr.GetVNetRule(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMVNetRule(out, rp)) + + return + } + + items, err := vr.ListVNetRules(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armVNetRule, 0, len(items)) + for i := range items { + out = append(out, toARMVNetRule(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armVNetRule]{Value: out}) +} + +func toARMVNetRule(vr *rdsdriver.VNetRule, rp *azurearm.ResourcePath) armVNetRule { + return armVNetRule{ + ID: childID(rp, subVNetRules, vr.Name), + Name: vr.Name, + Type: providerName + "/" + resourceServers + "/" + subVNetRules, + Properties: &armVNetRuleCfg{ + VirtualNetworkSubnetID: vr.SubnetID, + IgnoreMissingVnetServiceEndpoint: vr.IgnoreMissingEndpoint, + State: vr.State, + }, + } +} + +// ---- Elastic pools ---- + +type armElasticPool struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + SKU *armSKU `json:"sku,omitempty"` + Properties *armElasticPoolCfg `json:"properties,omitempty"` +} + +type armElasticPoolCfg struct { + MaxSizeBytes int64 `json:"maxSizeBytes,omitempty"` + State string `json:"state,omitempty"` + PerDatabaseSetting *armPerDatabaseSeters `json:"perDatabaseSettings,omitempty"` +} + +type armPerDatabaseSeters struct { + MinCapacity float64 `json:"minCapacity,omitempty"` + MaxCapacity float64 `json:"maxCapacity,omitempty"` +} + +func (h *Handler) serveElasticPool(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + ep, ok := h.elasticPools() + if !ok { + writeUnsupported(w, "elasticPools") + return + } + + if rp.SubResourceName == "" { + h.getOrListPool(w, r, rp, ep, true) + return + } + + switch r.Method { + case http.MethodPut: + h.writeElasticPool(w, r, rp, ep, false) + case http.MethodPatch: + h.writeElasticPool(w, r, rp, ep, true) + case http.MethodGet: + h.getOrListPool(w, r, rp, ep, false) + case http.MethodDelete: + if err := ep.DeleteElasticPool(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +// writeElasticPool handles PUT (create-or-replace) and PATCH (merge): merge +// applies only the fields the request supplied, matching Azure's PATCH. +func (*Handler) writeElasticPool( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, ep rdsdriver.ElasticPools, merge bool, +) { + var body armElasticPool + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.ElasticPoolConfig{Server: rp.ResourceName, Name: rp.SubResourceName, Location: body.Location} + if body.SKU != nil { + cfg.SKUName = body.SKU.Name + cfg.SKUTier = body.SKU.Tier + } + + if body.Properties != nil { + cfg.MaxSizeBytes = body.Properties.MaxSizeBytes + if body.Properties.PerDatabaseSetting != nil { + cfg.MinCapacity = body.Properties.PerDatabaseSetting.MinCapacity + cfg.MaxCapacity = body.Properties.PerDatabaseSetting.MaxCapacity + } + } + + write := ep.CreateElasticPool + if merge { + write = ep.UpdateElasticPool + } + + out, err := write(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMElasticPool(out, rp)) +} + +//nolint:dupl // mirrors the sibling get/list handler by design. +func (*Handler) getOrListPool( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, ep rdsdriver.ElasticPools, list bool, +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + if !list { + out, err := ep.GetElasticPool(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMElasticPool(out, rp)) + + return + } + + items, err := ep.ListElasticPools(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armElasticPool, 0, len(items)) + for i := range items { + out = append(out, toARMElasticPool(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armElasticPool]{Value: out}) +} + +func toARMElasticPool(ep *rdsdriver.ElasticPool, rp *azurearm.ResourcePath) armElasticPool { + return armElasticPool{ + ID: childID(rp, subElasticPools, ep.Name), + Name: ep.Name, + Type: providerName + "/" + resourceServers + "/" + subElasticPools, + Location: ep.Location, + SKU: &armSKU{Name: ep.SKUName, Tier: ep.SKUTier}, + Properties: &armElasticPoolCfg{ + MaxSizeBytes: ep.MaxSizeBytes, + State: ep.State, + PerDatabaseSetting: &armPerDatabaseSeters{MinCapacity: ep.MinCapacity, MaxCapacity: ep.MaxCapacity}, + }, + } +} + +// ---- Failover groups ---- + +type armFailoverGroup struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armFailoverGroupCfg `json:"properties,omitempty"` +} + +type armFailoverGroupCfg struct { + ReadWriteEndpoint *armRWEndpoint `json:"readWriteEndpoint,omitempty"` + PartnerServers []armPartner `json:"partnerServers,omitempty"` + Databases []string `json:"databases,omitempty"` + ReplicationRole string `json:"replicationRole,omitempty"` + ReplicationState string `json:"replicationState,omitempty"` +} + +type armRWEndpoint struct { + FailoverPolicy string `json:"failoverPolicy,omitempty"` + FailoverWithDataLossGracePeriodMinutes int32 `json:"failoverWithDataLossGracePeriodMinutes,omitempty"` +} + +type armPartner struct { + ID string `json:"id,omitempty"` +} + +func (h *Handler) serveFailoverGroup(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + fg, ok := h.failoverGroups() + if !ok { + writeUnsupported(w, "failoverGroups") + return + } + + if rp.SubResourceName == "" { + h.getOrListFG(w, r, rp, fg, true) + return + } + + switch r.Method { + case http.MethodPut: + h.writeFailoverGroup(w, r, rp, fg, false) + case http.MethodPatch: + h.writeFailoverGroup(w, r, rp, fg, true) + case http.MethodGet: + h.getOrListFG(w, r, rp, fg, false) + case http.MethodPost: // .../failoverGroups/{name}/failover (and force/tryPlanned variants) + h.doFailover(w, r, rp, fg) + case http.MethodDelete: + if err := fg.DeleteFailoverGroup(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +// writeFailoverGroup handles PUT (create-or-replace) and PATCH (merge). +func (*Handler) writeFailoverGroup( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fg rdsdriver.FailoverGroups, merge bool, +) { + var body armFailoverGroup + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.FailoverGroupConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.Databases = body.Properties.Databases + + if body.Properties.ReadWriteEndpoint != nil { + cfg.FailoverPolicy = body.Properties.ReadWriteEndpoint.FailoverPolicy + cfg.GracePeriodMinutes = body.Properties.ReadWriteEndpoint.FailoverWithDataLossGracePeriodMinutes + } + + for _, p := range body.Properties.PartnerServers { + cfg.PartnerServers = append(cfg.PartnerServers, p.ID) + } + } + + write := fg.CreateFailoverGroup + if merge { + write = fg.UpdateFailoverGroup + } + + out, err := write(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFailoverGroup(out, rp)) +} + +func (*Handler) doFailover( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fg rdsdriver.FailoverGroups, +) { + // The action verb is the segment after the group name. Only the two real + // failover verbs are accepted; an unknown POST verb must 404 rather than be + // silently treated as a planned failover. + switch rp.SubResourceAction { + case actionFailover, actionForceFailover: + default: + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported failover-group action: "+rp.SubResourceAction) + return + } + + out, err := fg.FailoverFailoverGroup(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFailoverGroup(out, rp)) +} + +//nolint:dupl // mirrors the sibling get/list handler by design. +func (*Handler) getOrListFG( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fg rdsdriver.FailoverGroups, list bool, +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + if !list { + out, err := fg.GetFailoverGroup(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFailoverGroup(out, rp)) + + return + } + + items, err := fg.ListFailoverGroups(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armFailoverGroup, 0, len(items)) + for i := range items { + out = append(out, toARMFailoverGroup(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armFailoverGroup]{Value: out}) +} + +func toARMFailoverGroup(fg *rdsdriver.FailoverGroup, rp *azurearm.ResourcePath) armFailoverGroup { + partners := make([]armPartner, 0, len(fg.PartnerServers)) + for _, id := range fg.PartnerServers { + partners = append(partners, armPartner{ID: id}) + } + + return armFailoverGroup{ + ID: childID(rp, subFailoverGroups, fg.Name), + Name: fg.Name, + Type: providerName + "/" + resourceServers + "/" + subFailoverGroups, + Properties: &armFailoverGroupCfg{ + ReadWriteEndpoint: &armRWEndpoint{ + FailoverPolicy: fg.FailoverPolicy, + FailoverWithDataLossGracePeriodMinutes: fg.GracePeriodMinutes, + }, + PartnerServers: partners, + Databases: fg.Databases, + ReplicationRole: fg.ReplicationRole, + ReplicationState: "CATCH_UP", + }, + } +} + +// ---- Azure AD administrator ---- + +type armAADAdmin struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armAADAdminCfg `json:"properties,omitempty"` +} + +type armAADAdminCfg struct { + AdministratorType string `json:"administratorType,omitempty"` + Login string `json:"login,omitempty"` + Sid string `json:"sid,omitempty"` + TenantID string `json:"tenantId,omitempty"` +} + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveAADAdmin(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + aad, ok := h.aadAdmins() + if !ok { + writeUnsupported(w, "administrators") + return + } + + if rp.SubResourceName == "" { + h.listAADAdmins(w, r, rp, aad) + return + } + + switch r.Method { + case http.MethodPut: + h.putAADAdmin(w, r, rp, aad) + case http.MethodGet: + out, err := aad.GetAADAdmin(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMAADAdmin(out, rp)) + case http.MethodDelete: + if err := aad.DeleteAADAdmin(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putAADAdmin( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, aad rdsdriver.AADAdmins, +) { + var body armAADAdmin + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.AADAdminConfig{Server: rp.ResourceName} + if body.Properties != nil { + cfg.Login = body.Properties.Login + cfg.SID = body.Properties.Sid + cfg.TenantID = body.Properties.TenantID + } + + out, err := aad.SetAADAdmin(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMAADAdmin(out, rp)) +} + +func (*Handler) listAADAdmins( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, aad rdsdriver.AADAdmins, +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + items, err := aad.ListAADAdmins(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armAADAdmin, 0, len(items)) + for i := range items { + out = append(out, toARMAADAdmin(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armAADAdmin]{Value: out}) +} + +func toARMAADAdmin(a *rdsdriver.AADAdmin, rp *azurearm.ResourcePath) armAADAdmin { + return armAADAdmin{ + ID: childID(rp, subAdministrators, a.Name), + Name: a.Name, + Type: providerName + "/" + resourceServers + "/" + subAdministrators, + Properties: &armAADAdminCfg{ + AdministratorType: aadAdminType, + Login: a.Login, + Sid: a.SID, + TenantID: a.TenantID, + }, + } +} + +const aadAdminType = "ActiveDirectory" diff --git a/server/azure/azuresql/subresources_sdk_test.go b/server/azure/azuresql/subresources_sdk_test.go new file mode 100644 index 00000000..24f4ec1b --- /dev/null +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -0,0 +1,538 @@ +package azuresql_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" +) + +func mustCreateSQLServer(t *testing.T, cf *armsql.ClientFactory) { + t.Helper() + + ctx := context.Background() + + poller, err := cf.NewServersClient().BeginCreateOrUpdate(ctx, "rg-1", "srv1", armsql.Server{ + Location: to.Ptr("eastus"), + Properties: &armsql.ServerProperties{ + AdministratorLogin: to.Ptr("admin"), + AdministratorLoginPassword: to.Ptr("Sup3rs3cret!"), + Version: to.Ptr("12.0"), + }, + }, nil) + if err != nil { + t.Fatalf("server BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("server PollUntilDone: %v", err) + } +} + +func TestSDKAzureSQLFirewallRules(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + fw := cf.NewFirewallRulesClient() + + if _, err := fw.CreateOrUpdate(ctx, "rg-1", "srv1", "office", armsql.FirewallRule{ + Properties: &armsql.ServerFirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.1"), + EndIPAddress: to.Ptr("10.0.0.255"), + }, + }, nil); err != nil { + t.Fatalf("CreateOrUpdate: %v", err) + } + + got, err := fw.Get(ctx, "rg-1", "srv1", "office", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.StartIPAddress == nil || *got.Properties.StartIPAddress != "10.0.0.1" { + t.Fatalf("start ip: got %v", got.Properties) + } + + page, err := fw.NewListByServerPager("rg-1", "srv1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d firewall rules, want 1", len(page.Value)) + } + + if _, err := fw.Delete(ctx, "rg-1", "srv1", "office", nil); err != nil { + t.Fatalf("Delete: %v", err) + } + + if _, err := fw.Get(ctx, "rg-1", "srv1", "office", nil); err == nil { + t.Fatal("expected NotFound after firewall rule delete") + } +} + +func TestSDKAzureSQLVNetRules(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + vr := cf.NewVirtualNetworkRulesClient() + + poller, err := vr.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "vnet1", armsql.VirtualNetworkRule{ + Properties: &armsql.VirtualNetworkRuleProperties{ + VirtualNetworkSubnetID: to.Ptr("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vn/subnets/s1"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("vnet PollUntilDone: %v", err) + } + + got, err := vr.Get(ctx, "rg-1", "srv1", "vnet1", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.VirtualNetworkSubnetID == nil { + t.Fatal("expected subnet id set") + } + + page, err := vr.NewListByServerPager("rg-1", "srv1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d vnet rules, want 1", len(page.Value)) + } + + delPoller, err := vr.BeginDelete(ctx, "rg-1", "srv1", "vnet1", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("vnet delete PollUntilDone: %v", err) + } +} + +func TestSDKAzureSQLElasticPools(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + ep := cf.NewElasticPoolsClient() + + poller, err := ep.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "pool1", armsql.ElasticPool{ + Location: to.Ptr("eastus"), + SKU: &armsql.SKU{Name: to.Ptr("StandardPool"), Tier: to.Ptr("Standard")}, + Properties: &armsql.ElasticPoolProperties{ + MaxSizeBytes: to.Ptr(int64(107374182400)), + PerDatabaseSettings: &armsql.ElasticPoolPerDatabaseSettings{MinCapacity: to.Ptr(0.0), MaxCapacity: to.Ptr(50.0)}, + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("pool PollUntilDone: %v", err) + } + + got, err := ep.Get(ctx, "rg-1", "srv1", "pool1", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.SKU == nil || got.SKU.Name == nil || *got.SKU.Name != "StandardPool" { + t.Fatalf("sku: got %v", got.SKU) + } + + page, err := ep.NewListByServerPager("rg-1", "srv1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d pools, want 1", len(page.Value)) + } + + delPoller, err := ep.BeginDelete(ctx, "rg-1", "srv1", "pool1", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("pool delete PollUntilDone: %v", err) + } +} + +func TestSDKAzureSQLFailoverGroups(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + fg := cf.NewFailoverGroupsClient() + + poller, err := fg.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "fg1", armsql.FailoverGroup{ + Properties: &armsql.FailoverGroupProperties{ + ReadWriteEndpoint: &armsql.FailoverGroupReadWriteEndpoint{ + FailoverPolicy: to.Ptr(armsql.ReadWriteEndpointFailoverPolicyManual), + }, + PartnerServers: []*armsql.PartnerInfo{{ + ID: to.Ptr("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Sql/servers/partnersrv"), + }}, + Databases: []*string{to.Ptr("db1")}, + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fg PollUntilDone: %v", err) + } + + got, err := fg.Get(ctx, "rg-1", "srv1", "fg1", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.ReplicationRole == nil || + *got.Properties.ReplicationRole != armsql.FailoverGroupReplicationRolePrimary { + t.Fatalf("expected Primary role, got %v", got.Properties) + } + + // Failover flips the local role to Secondary. + foPoller, err := fg.BeginFailover(ctx, "rg-1", "srv1", "fg1", nil) + if err != nil { + t.Fatalf("BeginFailover: %v", err) + } + + foResp, err := foPoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("failover PollUntilDone: %v", err) + } + + if foResp.Properties == nil || foResp.Properties.ReplicationRole == nil || + *foResp.Properties.ReplicationRole != armsql.FailoverGroupReplicationRoleSecondary { + t.Fatalf("expected Secondary role after failover, got %v", foResp.Properties) + } + + // The forced-failover verb (a distinct 4th path segment) routes to the same + // action and flips the role back to Primary. + forcePoller, err := fg.BeginForceFailoverAllowDataLoss(ctx, "rg-1", "srv1", "fg1", nil) + if err != nil { + t.Fatalf("BeginForceFailoverAllowDataLoss: %v", err) + } + + forceResp, err := forcePoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("force failover PollUntilDone: %v", err) + } + + if forceResp.Properties == nil || forceResp.Properties.ReplicationRole == nil || + *forceResp.Properties.ReplicationRole != armsql.FailoverGroupReplicationRolePrimary { + t.Fatalf("expected Primary role after force failover, got %v", forceResp.Properties) + } + + // PATCH (BeginUpdate) merges — changing the grace period keeps the partner. + patchPoller, err := fg.BeginUpdate(ctx, "rg-1", "srv1", "fg1", armsql.FailoverGroupUpdate{ + Properties: &armsql.FailoverGroupUpdateProperties{ + ReadWriteEndpoint: &armsql.FailoverGroupReadWriteEndpoint{ + FailoverPolicy: to.Ptr(armsql.ReadWriteEndpointFailoverPolicyAutomatic), + FailoverWithDataLossGracePeriodMinutes: to.Ptr(int32(120)), + }, + }, + }, nil) + if err != nil { + t.Fatalf("fg BeginUpdate: %v", err) + } + + if _, err := patchPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fg patch PollUntilDone: %v", err) + } + + // List the failover groups on the server. + fgPage, err := fg.NewListByServerPager("rg-1", "srv1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("fg List: %v", err) + } + + if len(fgPage.Value) != 1 { + t.Fatalf("got %d failover groups, want 1", len(fgPage.Value)) + } + + delPoller, err := fg.BeginDelete(ctx, "rg-1", "srv1", "fg1", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fg delete PollUntilDone: %v", err) + } +} + +func TestSDKAzureSQLAADAdmin(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + aad := cf.NewServerAzureADAdministratorsClient() + + poller, err := aad.BeginCreateOrUpdate(ctx, "rg-1", "srv1", armsql.AdministratorNameActiveDirectory, + armsql.ServerAzureADAdministrator{ + Properties: &armsql.AdministratorProperties{ + AdministratorType: to.Ptr(armsql.AdministratorTypeActiveDirectory), + Login: to.Ptr("dba-group"), + Sid: to.Ptr("00000000-0000-0000-0000-000000000001"), + TenantID: to.Ptr("00000000-0000-0000-0000-0000000000ff"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("aad PollUntilDone: %v", err) + } + + got, err := aad.Get(ctx, "rg-1", "srv1", armsql.AdministratorNameActiveDirectory, nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.Login == nil || *got.Properties.Login != "dba-group" { + t.Fatalf("login: got %v", got.Properties) + } + + page, err := aad.NewListByServerPager("rg-1", "srv1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d admins, want 1", len(page.Value)) + } + + delPoller, err := aad.BeginDelete(ctx, "rg-1", "srv1", armsql.AdministratorNameActiveDirectory, nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("aad delete PollUntilDone: %v", err) + } +} + +func TestSDKAzureSQLManagedInstances(t *testing.T) { + cf := newFactory(t) + ctx := context.Background() + + mic := cf.NewManagedInstancesClient() + + poller, err := mic.BeginCreateOrUpdate(ctx, "rg-1", "mi1", armsql.ManagedInstance{ + Location: to.Ptr("eastus"), + SKU: &armsql.SKU{Name: to.Ptr("GP_Gen5"), Tier: to.Ptr("GeneralPurpose")}, + Properties: &armsql.ManagedInstanceProperties{ + AdministratorLogin: to.Ptr("miadmin"), + VCores: to.Ptr(int32(4)), + StorageSizeInGB: to.Ptr(int32(32)), + SubnetID: to.Ptr("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vn/subnets/mi"), + }, + }, nil) + if err != nil { + t.Fatalf("MI BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MI PollUntilDone: %v", err) + } + + got, err := mic.Get(ctx, "rg-1", "mi1", nil) + if err != nil { + t.Fatalf("MI Get: %v", err) + } + + if got.Properties == nil || got.Properties.AdministratorLogin == nil || *got.Properties.AdministratorLogin != "miadmin" { + t.Fatalf("MI admin login: got %v", got.Properties) + } + + page, err := mic.NewListByResourceGroupPager("rg-1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("MI List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d managed instances, want 1", len(page.Value)) + } + + // Failover (the managed-instance lifecycle action this SDK version exposes). + foPoller, err := mic.BeginFailover(ctx, "rg-1", "mi1", nil) + if err != nil { + t.Fatalf("MI BeginFailover: %v", err) + } + + if _, err := foPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MI failover: %v", err) + } + + // Managed database. + mdc := cf.NewManagedDatabasesClient() + + dbPoller, err := mdc.BeginCreateOrUpdate(ctx, "rg-1", "mi1", "appdb", armsql.ManagedDatabase{ + Location: to.Ptr("eastus"), + Properties: &armsql.ManagedDatabaseProperties{Collation: to.Ptr("SQL_Latin1_General_CP1_CI_AS")}, + }, nil) + if err != nil { + t.Fatalf("MDB BeginCreateOrUpdate: %v", err) + } + + if _, err := dbPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MDB PollUntilDone: %v", err) + } + + gotDB, err := mdc.Get(ctx, "rg-1", "mi1", "appdb", nil) + if err != nil { + t.Fatalf("MDB Get: %v", err) + } + + if gotDB.Name == nil || *gotDB.Name != "appdb" { + t.Fatalf("MDB name: got %v", gotDB.Name) + } + + dbPage, err := mdc.NewListByInstancePager("rg-1", "mi1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("MDB List: %v", err) + } + + if len(dbPage.Value) != 1 { + t.Fatalf("got %d managed databases, want 1", len(dbPage.Value)) + } + + // Explicitly delete the managed database (DeleteManagedDatabase handler). + mdbDelPoller, err := mdc.BeginDelete(ctx, "rg-1", "mi1", "appdb", nil) + if err != nil { + t.Fatalf("MDB BeginDelete: %v", err) + } + + if _, err := mdbDelPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MDB delete: %v", err) + } + + if _, err := mdc.Get(ctx, "rg-1", "mi1", "appdb", nil); err == nil { + t.Fatal("expected NotFound after managed database delete") + } + + // Delete the instance; managed databases cascade. + delPoller, err := mic.BeginDelete(ctx, "rg-1", "mi1", nil) + if err != nil { + t.Fatalf("MI BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MI delete: %v", err) + } + + if _, err := mic.Get(ctx, "rg-1", "mi1", nil); err == nil { + t.Fatal("expected NotFound after managed instance delete") + } +} + +func TestSDKAzureSQLElasticPoolPatchMerge(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + ep := cf.NewElasticPoolsClient() + + poller, err := ep.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "pool1", armsql.ElasticPool{ + Location: to.Ptr("eastus"), + SKU: &armsql.SKU{Name: to.Ptr("StandardPool"), Tier: to.Ptr("Standard")}, + Properties: &armsql.ElasticPoolProperties{MaxSizeBytes: to.Ptr(int64(107374182400))}, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("pool create: %v", err) + } + + // PATCH only maxSizeBytes — SKU must survive the merge. + up, err := ep.BeginUpdate(ctx, "rg-1", "srv1", "pool1", armsql.ElasticPoolUpdate{ + Properties: &armsql.ElasticPoolUpdateProperties{MaxSizeBytes: to.Ptr(int64(214748364800))}, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + if _, err := up.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("pool patch: %v", err) + } + + got, err := ep.Get(ctx, "rg-1", "srv1", "pool1", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.SKU == nil || got.SKU.Name == nil || *got.SKU.Name != "StandardPool" { + t.Fatalf("PATCH wiped the SKU: %v", got.SKU) + } + + if got.Properties == nil || got.Properties.MaxSizeBytes == nil || *got.Properties.MaxSizeBytes != 214748364800 { + t.Fatalf("PATCH did not apply maxSizeBytes: %v", got.Properties) + } +} + +func TestSDKAzureSQLManagedInstancePatchMerge(t *testing.T) { + cf := newFactory(t) + ctx := context.Background() + mic := cf.NewManagedInstancesClient() + + poller, err := mic.BeginCreateOrUpdate(ctx, "rg-1", "mi1", armsql.ManagedInstance{ + Location: to.Ptr("eastus"), + Properties: &armsql.ManagedInstanceProperties{ + AdministratorLogin: to.Ptr("miadmin"), + VCores: to.Ptr(int32(4)), + SubnetID: to.Ptr("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vn/subnets/mi"), + }, + }, nil) + if err != nil { + t.Fatalf("MI create: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MI create poll: %v", err) + } + + // PATCH only vCores — administratorLogin must survive the merge. + up, err := mic.BeginUpdate(ctx, "rg-1", "mi1", armsql.ManagedInstanceUpdate{ + Properties: &armsql.ManagedInstanceProperties{VCores: to.Ptr(int32(8))}, + }, nil) + if err != nil { + t.Fatalf("MI BeginUpdate: %v", err) + } + + if _, err := up.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("MI patch: %v", err) + } + + got, err := mic.Get(ctx, "rg-1", "mi1", nil) + if err != nil { + t.Fatalf("MI Get: %v", err) + } + + if got.Properties == nil || got.Properties.VCores == nil || *got.Properties.VCores != 8 { + t.Fatalf("PATCH did not apply vCores: %v", got.Properties) + } + + if got.Properties.AdministratorLogin == nil || *got.Properties.AdministratorLogin != "miadmin" { + t.Fatalf("PATCH wiped administratorLogin: %v", got.Properties) + } +} diff --git a/server/azure/azuresql/types.go b/server/azure/azuresql/types.go index a400d9ec..1556f9ad 100644 --- a/server/azure/azuresql/types.go +++ b/server/azure/azuresql/types.go @@ -57,6 +57,7 @@ type armDatabaseProps struct { Collation string `json:"collation,omitempty"` DatabaseID string `json:"databaseId,omitempty"` CurrentServiceObjectiveName string `json:"currentServiceObjectiveName,omitempty"` + ElasticPoolID string `json:"elasticPoolId,omitempty"` } // armList is the ARM list-response envelope. @@ -100,6 +101,7 @@ func toARMDatabase(inst *rdsdriver.Instance, subscription, resourceGroup string) Collation: "SQL_Latin1_General_CP1_CI_AS", DatabaseID: inst.ARN, CurrentServiceObjectiveName: inst.InstanceClass, + ElasticPoolID: inst.ElasticPoolID, }, } } diff --git a/server/azure/mysqlflex/error_paths_test.go b/server/azure/mysqlflex/error_paths_test.go new file mode 100644 index 00000000..06ec3f92 --- /dev/null +++ b/server/azure/mysqlflex/error_paths_test.go @@ -0,0 +1,65 @@ +package mysqlflex_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers" +) + +func statusOf(t *testing.T, err error) int { + t.Helper() + + var re *azcore.ResponseError + if !errors.As(err, &re) { + t.Fatalf("expected an azcore.ResponseError, got %T: %v", err, err) + } + + return re.StatusCode +} + +// Verifies the ARM wire layer maps canonical errors to HTTP status (parity with +// GCP's TestSDKCloudSQLErrorPaths). +func TestSDKMySQLFlexWireErrorMapping(t *testing.T) { + cf := newFactory(t) + ctx := context.Background() + + // 404 — server that doesn't exist. + if _, err := cf.NewServersClient().Get(ctx, "rg-1", "ghost", nil); err == nil { + t.Error("Get missing server: expected error") + } else if got := statusOf(t, err); got != http.StatusNotFound { + t.Errorf("Get missing server: status %d, want 404", got) + } + + mustCreateServer(t, cf) + + // 400 — firewall rule with start > end. + fw := cf.NewFirewallRulesClient() + + poller, err := fw.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "bad", armmysqlflexibleservers.FirewallRule{ + Properties: &armmysqlflexibleservers.FirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.9"), + EndIPAddress: to.Ptr("10.0.0.1"), + }, + }, nil) + if err == nil { + _, err = poller.PollUntilDone(ctx, nil) + } + + if err == nil { + t.Error("firewall rule with start > end: expected error") + } else if got := statusOf(t, err); got != http.StatusBadRequest { + t.Errorf("firewall start > end: status %d, want 400", got) + } + + // 404 — unknown server parameter. + if _, err := cf.NewConfigurationsClient().Get(ctx, "rg-1", "srv1", "not_a_real_param", nil); err == nil { + t.Error("Get unknown parameter: expected error") + } else if got := statusOf(t, err); got != http.StatusNotFound { + t.Errorf("Get unknown parameter: status %d, want 404", got) + } +} diff --git a/server/azure/mysqlflex/handler.go b/server/azure/mysqlflex/handler.go index 98f4a057..ab897449 100644 --- a/server/azure/mysqlflex/handler.go +++ b/server/azure/mysqlflex/handler.go @@ -30,9 +30,15 @@ const ( providerName = "Microsoft.DBforMySQL" resourceFlexServers = "flexibleServers" - subStart = "start" - subStop = "stop" - subRestart = "restart" + subStart = "start" + subStop = "stop" + subRestart = "restart" + subFailover = "failover" + + subDatabases = "databases" + subFirewallRules = "firewallRules" + subConfigurations = "configurations" + subUpdateConfigs = "updateConfigurations" ) // Handler serves Microsoft.DBforMySQL/flexibleServers ARM requests against a @@ -64,9 +70,23 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Action sub-resources: start, stop, restart. + // Child resources and server actions live under a server name. if rp.SubResource != "" { - h.serveAction(w, r, &rp) + switch rp.SubResource { + case subDatabases: + h.serveDatabase(w, r, &rp) + case subFirewallRules: + h.serveFirewallRule(w, r, &rp) + case subConfigurations: + h.serveConfiguration(w, r, &rp) + case subUpdateConfigs: + h.batchUpdateConfigurations(w, r, &rp) + case subStart, subStop, subRestart, subFailover: + h.serveAction(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported sub-resource: "+rp.SubResource) + } + return } @@ -116,6 +136,8 @@ func (h *Handler) serveAction(w http.ResponseWriter, r *http.Request, rp *azurea h.stopServer(w, r, rp) case subRestart: h.restartServer(w, r, rp) + case subFailover: + h.failoverServer(w, r, rp) default: azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported action: "+rp.SubResource) } diff --git a/server/azure/mysqlflex/operations.go b/server/azure/mysqlflex/operations.go index 4c3c138b..481c5922 100644 --- a/server/azure/mysqlflex/operations.go +++ b/server/azure/mysqlflex/operations.go @@ -90,6 +90,11 @@ func (h *Handler) getServer(w http.ResponseWriter, r *http.Request, rp *azurearm return } + if len(insts) == 0 { + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "server "+rp.ResourceName+" not found") + return + } + azurearm.WriteJSON(w, http.StatusOK, toARMServer(&insts[0], rp.Subscription, rp.ResourceGroup)) } @@ -147,13 +152,8 @@ func (h *Handler) restartServer(w http.ResponseWriter, r *http.Request, rp *azur } // respondWithServer fetches the current server state and writes it as the -// action response so the SDK's LRO poller observes a typed body. +// action response so the SDK's LRO poller observes a typed body. It is the same +// fetch-and-write as getServer. func (h *Handler) respondWithServer(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { - insts, err := h.db.DescribeInstances(r.Context(), []string{rp.ResourceName}) - if err != nil { - azurearm.WriteCErr(w, err) - return - } - - azurearm.WriteJSON(w, http.StatusOK, toARMServer(&insts[0], rp.Subscription, rp.ResourceGroup)) + h.getServer(w, r, rp) } diff --git a/server/azure/mysqlflex/sdk_roundtrip_test.go b/server/azure/mysqlflex/sdk_roundtrip_test.go index 57952db3..b2706987 100644 --- a/server/azure/mysqlflex/sdk_roundtrip_test.go +++ b/server/azure/mysqlflex/sdk_roundtrip_test.go @@ -23,7 +23,7 @@ func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcor return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil } -func newSDKClient(t *testing.T) *armmysqlflexibleservers.ServersClient { +func newFactory(t *testing.T) *armmysqlflexibleservers.ClientFactory { t.Helper() cloudP := cloudemu.NewAzure() @@ -55,7 +55,13 @@ func newSDKClient(t *testing.T) *armmysqlflexibleservers.ServersClient { t.Fatal(err) } - return cf.NewServersClient() + return cf +} + +func newSDKClient(t *testing.T) *armmysqlflexibleservers.ServersClient { + t.Helper() + + return newFactory(t).NewServersClient() } func TestSDKMySQLFlexLifecycle(t *testing.T) { diff --git a/server/azure/mysqlflex/subresources.go b/server/azure/mysqlflex/subresources.go new file mode 100644 index 00000000..bd1232f1 --- /dev/null +++ b/server/azure/mysqlflex/subresources.go @@ -0,0 +1,469 @@ +package mysqlflex + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// ---- ARM JSON shapes for child resources ---- + +type armDatabase struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armDatabaseCfg `json:"properties,omitempty"` +} + +type armDatabaseCfg struct { + Charset string `json:"charset,omitempty"` + Collation string `json:"collation,omitempty"` +} + +type armFirewallRule struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armFirewallRuleCfg `json:"properties,omitempty"` +} + +type armFirewallRuleCfg struct { + StartIPAddress string `json:"startIpAddress,omitempty"` + EndIPAddress string `json:"endIpAddress,omitempty"` +} + +type armConfiguration struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armConfigCfg `json:"properties,omitempty"` +} + +type armConfigCfg struct { + Value string `json:"value,omitempty"` + Source string `json:"source,omitempty"` + DataType string `json:"dataType,omitempty"` + DefaultValue string `json:"defaultValue,omitempty"` + AllowedValues string `json:"allowedValues,omitempty"` +} + +// armConfigBatch is the ConfigurationListForBatchUpdate request body. +type armConfigBatch struct { + Value []armConfiguration `json:"value"` +} + +func childResourceID(rp *azurearm.ResourcePath, subType, name string) string { + return armServerID(rp.Subscription, rp.ResourceGroup, rp.ResourceName) + "/" + subType + "/" + name +} + +// ---- capability accessors ---- + +func (h *Handler) databases() (rdsdriver.Databases, bool) { + d, ok := h.db.(rdsdriver.Databases) + return d, ok +} + +func (h *Handler) firewallRules() (rdsdriver.FirewallRules, bool) { + f, ok := h.db.(rdsdriver.FirewallRules) + return f, ok +} + +func (h *Handler) configurations() (rdsdriver.Configurations, bool) { + c, ok := h.db.(rdsdriver.Configurations) + return c, ok +} + +func (h *Handler) failoverCap() (rdsdriver.Failover, bool) { + f, ok := h.db.(rdsdriver.Failover) + return f, ok +} + +func writeUnsupported(w http.ResponseWriter, what string) { + azurearm.WriteError(w, http.StatusBadRequest, "OperationNotSupported", what+" is not supported by this driver") +} + +// ---- Databases ---- + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + db, ok := h.databases() + if !ok { + writeUnsupported(w, "databases") + return + } + + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.listDatabases(w, r, rp, db) + + return + } + + switch r.Method { + case http.MethodPut: + h.putDatabase(w, r, rp, db) + case http.MethodGet: + h.getDatabase(w, r, rp, db) + case http.MethodDelete: + h.deleteDatabase(w, r, rp, db) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases) { + var body armDatabase + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.DatabaseConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.Charset = body.Properties.Charset + cfg.Collation = body.Properties.Collation + } + + out, err := db.CreateDatabase(r.Context(), cfg) + if err != nil { + existing, getErr := db.GetDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) + if getErr != nil { + azurearm.WriteCErr(w, err) + return + } + + out = existing + } + + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(out, rp)) +} + +func (*Handler) getDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases) { + out, err := db.GetDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(out, rp)) +} + +func (*Handler) deleteDatabase( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases, +) { + if err := db.DeleteDatabase(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) +} + +//nolint:dupl // mirrors the sibling list handler by design. +func (*Handler) listDatabases( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases, +) { + items, err := db.ListDatabases(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armDatabase, 0, len(items)) + for i := range items { + out = append(out, toARMDatabase(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armDatabase]{Value: out}) +} + +func toARMDatabase(db *rdsdriver.Database, rp *azurearm.ResourcePath) armDatabase { + return armDatabase{ + ID: childResourceID(rp, subDatabases, db.Name), + Name: db.Name, + Type: providerName + "/" + resourceFlexServers + "/" + subDatabases, + Properties: &armDatabaseCfg{Charset: db.Charset, Collation: db.Collation}, + } +} + +// ---- Firewall rules ---- + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveFirewallRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + fw, ok := h.firewallRules() + if !ok { + writeUnsupported(w, "firewallRules") + return + } + + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.listFirewallRules(w, r, rp, fw) + + return + } + + switch r.Method { + case http.MethodPut: + h.putFirewallRule(w, r, rp, fw) + case http.MethodGet: + h.getFirewallRule(w, r, rp, fw) + case http.MethodDelete: + h.deleteFirewallRule(w, r, rp, fw) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + var body armFirewallRule + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.FirewallRuleConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.StartIPAddress = body.Properties.StartIPAddress + cfg.EndIPAddress = body.Properties.EndIPAddress + } + + out, err := fw.CreateFirewallRule(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(out, rp)) +} + +func (*Handler) getFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + out, err := fw.GetFirewallRule(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(out, rp)) +} + +func (*Handler) deleteFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + if err := fw.DeleteFirewallRule(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) +} + +//nolint:dupl // mirrors the sibling list handler by design. +func (*Handler) listFirewallRules( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + items, err := fw.ListFirewallRules(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armFirewallRule, 0, len(items)) + for i := range items { + out = append(out, toARMFirewallRule(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armFirewallRule]{Value: out}) +} + +func toARMFirewallRule(fw *rdsdriver.FirewallRule, rp *azurearm.ResourcePath) armFirewallRule { + return armFirewallRule{ + ID: childResourceID(rp, subFirewallRules, fw.Name), + Name: fw.Name, + Type: providerName + "/" + resourceFlexServers + "/" + subFirewallRules, + Properties: &armFirewallRuleCfg{ + StartIPAddress: fw.StartIPAddress, + EndIPAddress: fw.EndIPAddress, + }, + } +} + +// ---- Configurations ---- + +func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + cf, ok := h.configurations() + if !ok { + writeUnsupported(w, "configurations") + return + } + + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.listConfigurations(w, r, rp, cf) + + return + } + + switch r.Method { + case http.MethodPut, http.MethodPatch: + h.putConfiguration(w, r, rp, cf) + case http.MethodGet: + h.getConfiguration(w, r, rp, cf) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putConfiguration( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, cf rdsdriver.Configurations, +) { + var body armConfiguration + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.ConfigurationConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.Value = body.Properties.Value + } + + out, err := cf.SetConfiguration(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMConfiguration(out, rp)) +} + +func (*Handler) getConfiguration( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, cf rdsdriver.Configurations, +) { + out, err := cf.GetConfiguration(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMConfiguration(out, rp)) +} + +func (*Handler) listConfigurations( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, cf rdsdriver.Configurations, +) { + items, err := cf.ListConfigurations(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armConfiguration]{Value: configsToARM(items, rp)}) +} + +// batchUpdateConfigurations handles POST .../updateConfigurations, applying each +// entry and returning the resulting list. +func (h *Handler) batchUpdateConfigurations(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w) + return + } + + cf, ok := h.configurations() + if !ok { + writeUnsupported(w, "configurations") + return + } + + var body armConfigBatch + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfgs := make([]rdsdriver.ConfigurationConfig, 0, len(body.Value)) + + for i := range body.Value { + cfg := rdsdriver.ConfigurationConfig{Server: rp.ResourceName, Name: body.Value[i].Name} + if body.Value[i].Properties != nil { + cfg.Value = body.Value[i].Properties.Value + } + + cfgs = append(cfgs, cfg) + } + + // Apply the batch atomically — a bad entry must not leave earlier ones + // persisted — via the BatchConfigurations capability. + batch, ok := cf.(rdsdriver.BatchConfigurations) + if !ok { + writeUnsupported(w, "updateConfigurations") + return + } + + if _, err := batch.BatchSetConfigurations(r.Context(), rp.ResourceName, cfgs); err != nil { + azurearm.WriteCErr(w, err) + return + } + + items, err := cf.ListConfigurations(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armConfiguration]{Value: configsToARM(items, rp)}) +} + +func configsToARM(items []rdsdriver.Configuration, rp *azurearm.ResourcePath) []armConfiguration { + out := make([]armConfiguration, 0, len(items)) + for i := range items { + out = append(out, toARMConfiguration(&items[i], rp)) + } + + return out +} + +func toARMConfiguration(c *rdsdriver.Configuration, rp *azurearm.ResourcePath) armConfiguration { + return armConfiguration{ + ID: childResourceID(rp, subConfigurations, c.Name), + Name: c.Name, + Type: providerName + "/" + resourceFlexServers + "/" + subConfigurations, + Properties: &armConfigCfg{ + Value: c.Value, + Source: c.Source, + DataType: c.DataType, + DefaultValue: c.DefaultValue, + AllowedValues: c.AllowedValues, + }, + } +} + +// ---- Failover ---- + +func (h *Handler) failoverServer(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + fo, ok := h.failoverCap() + if !ok { + writeUnsupported(w, "failover") + return + } + + if err := fo.FailoverInstance(r.Context(), rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + h.respondWithServer(w, r, rp) +} diff --git a/server/azure/mysqlflex/subresources_sdk_test.go b/server/azure/mysqlflex/subresources_sdk_test.go new file mode 100644 index 00000000..f985b151 --- /dev/null +++ b/server/azure/mysqlflex/subresources_sdk_test.go @@ -0,0 +1,236 @@ +package mysqlflex_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/mysql/armmysqlflexibleservers" +) + +func mustCreateServer(t *testing.T, cf *armmysqlflexibleservers.ClientFactory) { + t.Helper() + + ctx := context.Background() + + poller, err := cf.NewServersClient().BeginCreate(ctx, "rg-1", "srv1", armmysqlflexibleservers.Server{ + Location: to.Ptr("eastus"), + }, nil) + if err != nil { + t.Fatalf("BeginCreate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("Create PollUntilDone: %v", err) + } +} + +func TestSDKMySQLFlexDatabases(t *testing.T) { + cf := newFactory(t) + mustCreateServer(t, cf) + + ctx := context.Background() + dbs := cf.NewDatabasesClient() + + poller, err := dbs.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "appdb", armmysqlflexibleservers.Database{ + Properties: &armmysqlflexibleservers.DatabaseProperties{ + Charset: to.Ptr("utf8mb4"), + Collation: to.Ptr("utf8mb4_unicode_ci"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("db create PollUntilDone: %v", err) + } + + got, err := dbs.Get(ctx, "rg-1", "srv1", "appdb", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.Charset == nil || *got.Properties.Charset != "utf8mb4" { + t.Fatalf("charset: got %v, want utf8mb4", got.Properties) + } + + pager := dbs.NewListByServerPager("rg-1", "srv1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d databases, want 1", len(page.Value)) + } + + delPoller, err := dbs.BeginDelete(ctx, "rg-1", "srv1", "appdb", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("db delete PollUntilDone: %v", err) + } + + if _, err := dbs.Get(ctx, "rg-1", "srv1", "appdb", nil); err == nil { + t.Fatal("expected NotFound after database delete") + } +} + +func TestSDKMySQLFlexFirewallRules(t *testing.T) { + cf := newFactory(t) + mustCreateServer(t, cf) + + ctx := context.Background() + fw := cf.NewFirewallRulesClient() + + poller, err := fw.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "office", armmysqlflexibleservers.FirewallRule{ + Properties: &armmysqlflexibleservers.FirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.1"), + EndIPAddress: to.Ptr("10.0.0.255"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fw create PollUntilDone: %v", err) + } + + got, err := fw.Get(ctx, "rg-1", "srv1", "office", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.StartIPAddress == nil || *got.Properties.StartIPAddress != "10.0.0.1" { + t.Fatalf("start ip: got %v, want 10.0.0.1", got.Properties) + } + + pager := fw.NewListByServerPager("rg-1", "srv1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d rules, want 1", len(page.Value)) + } + + delPoller, err := fw.BeginDelete(ctx, "rg-1", "srv1", "office", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fw delete PollUntilDone: %v", err) + } + + if _, err := fw.Get(ctx, "rg-1", "srv1", "office", nil); err == nil { + t.Fatal("expected NotFound after firewall rule delete") + } +} + +func TestSDKMySQLFlexConfigurations(t *testing.T) { + cf := newFactory(t) + mustCreateServer(t, cf) + + ctx := context.Background() + conf := cf.NewConfigurationsClient() + + poller, err := conf.BeginUpdate(ctx, "rg-1", "srv1", "max_connections", armmysqlflexibleservers.Configuration{ + Properties: &armmysqlflexibleservers.ConfigurationProperties{ + Value: to.Ptr("200"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("config update PollUntilDone: %v", err) + } + + got, err := conf.Get(ctx, "rg-1", "srv1", "max_connections", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.Value == nil || *got.Properties.Value != "200" { + t.Fatalf("value: got %v, want 200", got.Properties) + } + + batchPoller, err := conf.BeginBatchUpdate(ctx, "rg-1", "srv1", armmysqlflexibleservers.ConfigurationListForBatchUpdate{ + Value: []*armmysqlflexibleservers.ConfigurationForBatchUpdate{ + {Name: to.Ptr("slow_query_log"), Properties: &armmysqlflexibleservers.ConfigurationForBatchUpdateProperties{ + Value: to.Ptr("ON"), + }}, + }, + }, nil) + if err != nil { + t.Fatalf("BeginBatchUpdate: %v", err) + } + + if _, err := batchPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("batch PollUntilDone: %v", err) + } + + pager := conf.NewListByServerPager("rg-1", "srv1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + // List returns the full parameter catalog with the two overrides applied, + // not just the written parameters. + if len(page.Value) < 2 { + t.Fatalf("got %d configurations, want the catalog", len(page.Value)) + } + + values := map[string]string{} + for _, c := range page.Value { + if c.Name != nil && c.Properties != nil && c.Properties.Value != nil { + values[*c.Name] = *c.Properties.Value + } + } + + if values["max_connections"] != "200" { + t.Errorf("max_connections override missing: got %q", values["max_connections"]) + } + + if values["slow_query_log"] != "ON" { + t.Errorf("slow_query_log override missing: got %q", values["slow_query_log"]) + } +} + +func TestSDKMySQLFlexFailover(t *testing.T) { + cf := newFactory(t) + mustCreateServer(t, cf) + + ctx := context.Background() + servers := cf.NewServersClient() + + poller, err := servers.BeginFailover(ctx, "rg-1", "srv1", nil) + if err != nil { + t.Fatalf("BeginFailover: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("failover PollUntilDone: %v", err) + } + + got, err := servers.Get(ctx, "rg-1", "srv1", nil) + if err != nil { + t.Fatalf("Get after failover: %v", err) + } + + if got.Server.Properties == nil || got.Server.Properties.State == nil || + *got.Server.Properties.State != armmysqlflexibleservers.ServerStateReady { + t.Fatalf("expected Ready after failover, got %v", got.Server.Properties.State) + } +} diff --git a/server/azure/postgresflex/error_paths_test.go b/server/azure/postgresflex/error_paths_test.go new file mode 100644 index 00000000..b4f396ec --- /dev/null +++ b/server/azure/postgresflex/error_paths_test.go @@ -0,0 +1,66 @@ +package postgresflex_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers" +) + +func statusOf(t *testing.T, err error) int { + t.Helper() + + var re *azcore.ResponseError + if !errors.As(err, &re) { + t.Fatalf("expected an azcore.ResponseError, got %T: %v", err, err) + } + + return re.StatusCode +} + +// Verifies the ARM wire layer maps canonical errors to HTTP status (parity with +// GCP's TestSDKCloudSQLErrorPaths). +func TestSDKPostgresFlexWireErrorMapping(t *testing.T) { + opts := newClientOpts(t) + ctx := context.Background() + + servers, err := armpostgresqlflexibleservers.NewServersClient(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewServersClient: %v", err) + } + + // 404 — server that doesn't exist. + if _, err := servers.Get(ctx, "rg-1", "ghost", nil); err == nil { + t.Error("Get missing server: expected error") + } else if got := statusOf(t, err); got != http.StatusNotFound { + t.Errorf("Get missing server: status %d, want 404", got) + } + + mustCreateServer(t, opts) + + // 400 — firewall rule with start > end. + fw, err := armpostgresqlflexibleservers.NewFirewallRulesClient(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewFirewallRulesClient: %v", err) + } + + poller, err := fw.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "bad", armpostgresqlflexibleservers.FirewallRule{ + Properties: &armpostgresqlflexibleservers.FirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.9"), + EndIPAddress: to.Ptr("10.0.0.1"), + }, + }, nil) + if err == nil { + _, err = poller.PollUntilDone(ctx, nil) + } + + if err == nil { + t.Error("firewall rule with start > end: expected error") + } else if got := statusOf(t, err); got != http.StatusBadRequest { + t.Errorf("firewall start > end: status %d, want 400", got) + } +} diff --git a/server/azure/postgresflex/handler.go b/server/azure/postgresflex/handler.go index afc10a51..ec635164 100644 --- a/server/azure/postgresflex/handler.go +++ b/server/azure/postgresflex/handler.go @@ -34,6 +34,10 @@ const ( subResourceStart = "start" subResourceStop = "stop" subResourceRestart = "restart" + + subDatabases = "databases" + subFirewallRules = "firewallRules" + subConfigurations = "configurations" ) // Handler serves Microsoft.DBforPostgreSQL ARM requests against a @@ -65,9 +69,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Lifecycle action: .../flexibleServers/{name}/{start|stop|restart}. + // Child resources and lifecycle actions live under a server name. if rp.SubResource != "" { - h.serveLifecycleAction(w, r, &rp) + switch rp.SubResource { + case subDatabases: + h.serveDatabase(w, r, &rp) + case subFirewallRules: + h.serveFirewallRule(w, r, &rp) + case subConfigurations: + h.serveConfiguration(w, r, &rp) + case subResourceStart, subResourceStop, subResourceRestart: + h.serveLifecycleAction(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported sub-resource: "+rp.SubResource) + } + return } diff --git a/server/azure/postgresflex/operations.go b/server/azure/postgresflex/operations.go index 26ab6540..a067e54f 100644 --- a/server/azure/postgresflex/operations.go +++ b/server/azure/postgresflex/operations.go @@ -121,6 +121,11 @@ func (h *Handler) getServer(w http.ResponseWriter, r *http.Request, rp *azurearm return } + if len(insts) == 0 { + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "server "+rp.ResourceName+" not found") + return + } + azurearm.WriteJSON(w, http.StatusOK, toARMServer(&insts[0], rp.Subscription, rp.ResourceGroup)) } diff --git a/server/azure/postgresflex/sdk_roundtrip_test.go b/server/azure/postgresflex/sdk_roundtrip_test.go index 63ed1f9e..421c6df8 100644 --- a/server/azure/postgresflex/sdk_roundtrip_test.go +++ b/server/azure/postgresflex/sdk_roundtrip_test.go @@ -23,7 +23,13 @@ func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcor return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil } -func newSDKClient(t *testing.T) *armpostgresqlflexibleservers.ServersClient { +// subID is the subscription the SDK clients are constructed against. +const subID = "sub-1" + +// newClientOpts wires an in-memory Postgres Flex server and returns SDK client +// options pointed at it. Postgres Flex has no ClientFactory in this SDK version, +// so each test constructs the specific client it needs from these options. +func newClientOpts(t *testing.T) *arm.ClientOptions { t.Helper() cloudP := cloudemu.NewAzure() @@ -42,15 +48,19 @@ func newSDKClient(t *testing.T) *armpostgresqlflexibleservers.ServersClient { }, } - opts := &arm.ClientOptions{ + return &arm.ClientOptions{ ClientOptions: azcore.ClientOptions{ Cloud: myCloud, Transport: ts.Client(), Retry: policy.RetryOptions{MaxRetries: -1}, }, } +} + +func newSDKClient(t *testing.T) *armpostgresqlflexibleservers.ServersClient { + t.Helper() - c, err := armpostgresqlflexibleservers.NewServersClient("sub-1", fakeCred{}, opts) + c, err := armpostgresqlflexibleservers.NewServersClient(subID, fakeCred{}, newClientOpts(t)) if err != nil { t.Fatal(err) } diff --git a/server/azure/postgresflex/subresources.go b/server/azure/postgresflex/subresources.go new file mode 100644 index 00000000..dd65980b --- /dev/null +++ b/server/azure/postgresflex/subresources.go @@ -0,0 +1,388 @@ +package postgresflex + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// ---- ARM JSON shapes for child resources ---- + +type armDatabase struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armDatabaseCfg `json:"properties,omitempty"` +} + +type armDatabaseCfg struct { + Charset string `json:"charset,omitempty"` + Collation string `json:"collation,omitempty"` +} + +type armFirewallRule struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armFirewallRuleCfg `json:"properties,omitempty"` +} + +type armFirewallRuleCfg struct { + StartIPAddress string `json:"startIpAddress,omitempty"` + EndIPAddress string `json:"endIpAddress,omitempty"` +} + +type armConfiguration struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *armConfigCfg `json:"properties,omitempty"` +} + +type armConfigCfg struct { + Value string `json:"value,omitempty"` + Source string `json:"source,omitempty"` + DataType string `json:"dataType,omitempty"` + DefaultValue string `json:"defaultValue,omitempty"` + AllowedValues string `json:"allowedValues,omitempty"` +} + +func childResourceID(rp *azurearm.ResourcePath, subType, name string) string { + base := azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceFlexibleServers, rp.ResourceName) + return base + "/" + subType + "/" + name +} + +// ---- capability accessors ---- + +func (h *Handler) databases() (rdsdriver.Databases, bool) { + d, ok := h.db.(rdsdriver.Databases) + return d, ok +} + +func (h *Handler) firewallRules() (rdsdriver.FirewallRules, bool) { + f, ok := h.db.(rdsdriver.FirewallRules) + return f, ok +} + +func (h *Handler) configurations() (rdsdriver.Configurations, bool) { + c, ok := h.db.(rdsdriver.Configurations) + return c, ok +} + +func writeUnsupported(w http.ResponseWriter, what string) { + azurearm.WriteError(w, http.StatusBadRequest, "OperationNotSupported", what+" is not supported by this driver") +} + +// ---- Databases ---- + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + db, ok := h.databases() + if !ok { + writeUnsupported(w, "databases") + return + } + + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.listDatabases(w, r, rp, db) + + return + } + + switch r.Method { + case http.MethodPut: + h.putDatabase(w, r, rp, db) + case http.MethodGet: + h.getDatabase(w, r, rp, db) + case http.MethodDelete: + h.deleteDatabase(w, r, rp, db) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases) { + var body armDatabase + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.DatabaseConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.Charset = body.Properties.Charset + cfg.Collation = body.Properties.Collation + } + + out, err := db.CreateDatabase(r.Context(), cfg) + if err != nil { + existing, getErr := db.GetDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) + if getErr != nil { + azurearm.WriteCErr(w, err) + return + } + + out = existing + } + + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(out, rp)) +} + +func (*Handler) getDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases) { + out, err := db.GetDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(out, rp)) +} + +func (*Handler) deleteDatabase( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases, +) { + if err := db.DeleteDatabase(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) +} + +//nolint:dupl // mirrors the sibling list handler by design. +func (*Handler) listDatabases( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases, +) { + items, err := db.ListDatabases(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armDatabase, 0, len(items)) + for i := range items { + out = append(out, toARMDatabase(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armDatabase]{Value: out}) +} + +func toARMDatabase(db *rdsdriver.Database, rp *azurearm.ResourcePath) armDatabase { + return armDatabase{ + ID: childResourceID(rp, subDatabases, db.Name), + Name: db.Name, + Type: providerName + "/" + resourceFlexibleServers + "/" + subDatabases, + Properties: &armDatabaseCfg{Charset: db.Charset, Collation: db.Collation}, + } +} + +// ---- Firewall rules ---- + +//nolint:dupl // mirrors the sibling sub-resource handler by design. +func (h *Handler) serveFirewallRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + fw, ok := h.firewallRules() + if !ok { + writeUnsupported(w, "firewallRules") + return + } + + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.listFirewallRules(w, r, rp, fw) + + return + } + + switch r.Method { + case http.MethodPut: + h.putFirewallRule(w, r, rp, fw) + case http.MethodGet: + h.getFirewallRule(w, r, rp, fw) + case http.MethodDelete: + h.deleteFirewallRule(w, r, rp, fw) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + var body armFirewallRule + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.FirewallRuleConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.StartIPAddress = body.Properties.StartIPAddress + cfg.EndIPAddress = body.Properties.EndIPAddress + } + + out, err := fw.CreateFirewallRule(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(out, rp)) +} + +func (*Handler) getFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + out, err := fw.GetFirewallRule(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(out, rp)) +} + +func (*Handler) deleteFirewallRule( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + if err := fw.DeleteFirewallRule(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) +} + +//nolint:dupl // mirrors the sibling list handler by design. +func (*Handler) listFirewallRules( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fw rdsdriver.FirewallRules, +) { + items, err := fw.ListFirewallRules(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armFirewallRule, 0, len(items)) + for i := range items { + out = append(out, toARMFirewallRule(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armFirewallRule]{Value: out}) +} + +func toARMFirewallRule(fw *rdsdriver.FirewallRule, rp *azurearm.ResourcePath) armFirewallRule { + return armFirewallRule{ + ID: childResourceID(rp, subFirewallRules, fw.Name), + Name: fw.Name, + Type: providerName + "/" + resourceFlexibleServers + "/" + subFirewallRules, + Properties: &armFirewallRuleCfg{ + StartIPAddress: fw.StartIPAddress, + EndIPAddress: fw.EndIPAddress, + }, + } +} + +// ---- Configurations ---- + +func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + cf, ok := h.configurations() + if !ok { + writeUnsupported(w, "configurations") + return + } + + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.listConfigurations(w, r, rp, cf) + + return + } + + switch r.Method { + case http.MethodPut, http.MethodPatch: + h.putConfiguration(w, r, rp, cf) + case http.MethodGet: + h.getConfiguration(w, r, rp, cf) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) putConfiguration( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, cf rdsdriver.Configurations, +) { + var body armConfiguration + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := rdsdriver.ConfigurationConfig{Server: rp.ResourceName, Name: rp.SubResourceName} + if body.Properties != nil { + cfg.Value = body.Properties.Value + } + + out, err := cf.SetConfiguration(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMConfiguration(out, rp)) +} + +func (*Handler) getConfiguration( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, cf rdsdriver.Configurations, +) { + out, err := cf.GetConfiguration(r.Context(), rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMConfiguration(out, rp)) +} + +//nolint:dupl // mirrors the sibling list handler by design. +func (*Handler) listConfigurations( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, cf rdsdriver.Configurations, +) { + items, err := cf.ListConfigurations(r.Context(), rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]armConfiguration, 0, len(items)) + for i := range items { + out = append(out, toARMConfiguration(&items[i], rp)) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armConfiguration]{Value: out}) +} + +func toARMConfiguration(c *rdsdriver.Configuration, rp *azurearm.ResourcePath) armConfiguration { + return armConfiguration{ + ID: childResourceID(rp, subConfigurations, c.Name), + Name: c.Name, + Type: providerName + "/" + resourceFlexibleServers + "/" + subConfigurations, + Properties: &armConfigCfg{ + Value: c.Value, + Source: c.Source, + DataType: c.DataType, + DefaultValue: c.DefaultValue, + AllowedValues: c.AllowedValues, + }, + } +} diff --git a/server/azure/postgresflex/subresources_sdk_test.go b/server/azure/postgresflex/subresources_sdk_test.go new file mode 100644 index 00000000..92fc2f44 --- /dev/null +++ b/server/azure/postgresflex/subresources_sdk_test.go @@ -0,0 +1,208 @@ +package postgresflex_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers" +) + +func mustCreateServer(t *testing.T, opts *arm.ClientOptions) { + t.Helper() + + ctx := context.Background() + + servers, err := armpostgresqlflexibleservers.NewServersClient(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewServersClient: %v", err) + } + + poller, err := servers.BeginCreate(ctx, "rg-1", "srv1", armpostgresqlflexibleservers.Server{ + Location: to.Ptr("eastus"), + }, nil) + if err != nil { + t.Fatalf("BeginCreate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("Create PollUntilDone: %v", err) + } +} + +func TestSDKPostgresFlexDatabases(t *testing.T) { + opts := newClientOpts(t) + mustCreateServer(t, opts) + + ctx := context.Background() + + dbs, err := armpostgresqlflexibleservers.NewDatabasesClient(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewDatabasesClient: %v", err) + } + + poller, err := dbs.BeginCreate(ctx, "rg-1", "srv1", "appdb", armpostgresqlflexibleservers.Database{ + Properties: &armpostgresqlflexibleservers.DatabaseProperties{ + Charset: to.Ptr("UTF8"), + Collation: to.Ptr("en_US.utf8"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("db create PollUntilDone: %v", err) + } + + got, err := dbs.Get(ctx, "rg-1", "srv1", "appdb", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.Charset == nil || *got.Properties.Charset != "UTF8" { + t.Fatalf("charset: got %v, want UTF8", got.Properties) + } + + pager := dbs.NewListByServerPager("rg-1", "srv1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d databases, want 1", len(page.Value)) + } + + delPoller, err := dbs.BeginDelete(ctx, "rg-1", "srv1", "appdb", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("db delete PollUntilDone: %v", err) + } + + if _, err := dbs.Get(ctx, "rg-1", "srv1", "appdb", nil); err == nil { + t.Fatal("expected NotFound after database delete") + } +} + +func TestSDKPostgresFlexFirewallRules(t *testing.T) { + opts := newClientOpts(t) + mustCreateServer(t, opts) + + ctx := context.Background() + + fw, err := armpostgresqlflexibleservers.NewFirewallRulesClient(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewFirewallRulesClient: %v", err) + } + + poller, err := fw.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "office", armpostgresqlflexibleservers.FirewallRule{ + Properties: &armpostgresqlflexibleservers.FirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.1"), + EndIPAddress: to.Ptr("10.0.0.255"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fw create PollUntilDone: %v", err) + } + + got, err := fw.Get(ctx, "rg-1", "srv1", "office", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.StartIPAddress == nil || *got.Properties.StartIPAddress != "10.0.0.1" { + t.Fatalf("start ip: got %v, want 10.0.0.1", got.Properties) + } + + pager := fw.NewListByServerPager("rg-1", "srv1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d rules, want 1", len(page.Value)) + } + + delPoller, err := fw.BeginDelete(ctx, "rg-1", "srv1", "office", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("fw delete PollUntilDone: %v", err) + } + + if _, err := fw.Get(ctx, "rg-1", "srv1", "office", nil); err == nil { + t.Fatal("expected NotFound after firewall rule delete") + } +} + +func TestSDKPostgresFlexConfigurations(t *testing.T) { + opts := newClientOpts(t) + mustCreateServer(t, opts) + + ctx := context.Background() + + conf, err := armpostgresqlflexibleservers.NewConfigurationsClient(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewConfigurationsClient: %v", err) + } + + poller, err := conf.BeginUpdate(ctx, "rg-1", "srv1", "max_connections", armpostgresqlflexibleservers.Configuration{ + Properties: &armpostgresqlflexibleservers.ConfigurationProperties{ + Value: to.Ptr("200"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("config update PollUntilDone: %v", err) + } + + got, err := conf.Get(ctx, "rg-1", "srv1", "max_connections", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.Value == nil || *got.Properties.Value != "200" { + t.Fatalf("value: got %v, want 200", got.Properties) + } + + pager := conf.NewListByServerPager("rg-1", "srv1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + + // List returns the full parameter catalog with the override applied. + if len(page.Value) < 1 { + t.Fatalf("got %d configurations, want the catalog", len(page.Value)) + } + + var sawOverride bool + for _, c := range page.Value { + if c.Name != nil && *c.Name == "max_connections" && + c.Properties != nil && c.Properties.Value != nil && *c.Properties.Value == "200" { + sawOverride = true + } + } + + if !sawOverride { + t.Error("max_connections override missing from catalog list") + } +} diff --git a/server/azure/resourcegraph/handler.go b/server/azure/resourcegraph/handler.go index 98250902..2a984cf5 100644 --- a/server/azure/resourcegraph/handler.go +++ b/server/azure/resourcegraph/handler.go @@ -254,16 +254,20 @@ func extractSubscription(arn string) string { // carries. A map lookup rather than a switch keeps gocyclo under the gate as the // pairs grow. var portableToAzureTypeMap = map[string]string{ //nolint:gochecknoglobals // static lookup table - "compute/Instance": "microsoft.compute/virtualmachines", - "networking/VPC": "microsoft.network/virtualnetworks", - "networking/Subnet": "microsoft.network/subnets", - "networking/SecurityGroup": "microsoft.network/networksecuritygroups", - "storage/Bucket": "microsoft.storage/storageaccounts", - "database/Table": "microsoft.documentdb/databaseaccounts", - "serverless/Function": "microsoft.web/sites", - "databricks/Workspace": "microsoft.databricks/workspaces", - "kubernetes/Cluster": "microsoft.containerservice/managedclusters", - "kubernetes/NodeGroup": "microsoft.containerservice/managedclusters/agentpools", + "compute/Instance": "microsoft.compute/virtualmachines", + "networking/VPC": "microsoft.network/virtualnetworks", + "networking/Subnet": "microsoft.network/subnets", + "networking/SecurityGroup": "microsoft.network/networksecuritygroups", + "storage/Bucket": "microsoft.storage/storageaccounts", + "database/Table": "microsoft.documentdb/databaseaccounts", + "serverless/Function": "microsoft.web/sites", + "databricks/Workspace": "microsoft.databricks/workspaces", + "kubernetes/Cluster": "microsoft.containerservice/managedclusters", + "kubernetes/NodeGroup": "microsoft.containerservice/managedclusters/agentpools", + "relationaldb/SqlServer": "microsoft.sql/servers", + "relationaldb/SqlManagedInstance": "microsoft.sql/managedinstances", + "relationaldb/MySqlFlexibleServer": "microsoft.dbformysql/flexibleservers", + "relationaldb/PostgresFlexibleServer": "microsoft.dbforpostgresql/flexibleservers", } func portableToAzureType(service, typ string) string { diff --git a/server/azure/resourcegraph/kql.go b/server/azure/resourcegraph/kql.go index 19225bb1..8d70a783 100644 --- a/server/azure/resourcegraph/kql.go +++ b/server/azure/resourcegraph/kql.go @@ -47,17 +47,22 @@ const ( azureTypeDatabrick = "microsoft.databricks/workspaces" azureTypeAKS = "microsoft.containerservice/managedclusters" azureTypeAgentPool = "microsoft.containerservice/managedclusters/agentpools" + azureTypeSQL = "microsoft.sql/servers" + azureTypeSQLMI = "microsoft.sql/managedinstances" + azureTypeMySQLFlex = "microsoft.dbformysql/flexibleservers" + azureTypePgFlex = "microsoft.dbforpostgresql/flexibleservers" ) // Portable service identifiers as emitted by the resourcediscovery walkers. const ( - portableCompute = "compute" - portableNetworking = "networking" - portableStorage = "storage" - portableDatabase = "database" - portableServerless = "serverless" - portableDatabricks = "databricks" - portableKubernetes = "kubernetes" + portableCompute = "compute" + portableNetworking = "networking" + portableStorage = "storage" + portableDatabase = "database" + portableServerless = "serverless" + portableDatabricks = "databricks" + portableKubernetes = "kubernetes" + portableRelationalDB = "relationaldb" ) // parsedKQL is the result of KQL parsing — an engine Query plus the limit @@ -305,6 +310,10 @@ var azureToPortableType = map[string]portableResourceType{ //nolint:gochecknoglo azureTypeDatabrick: {portableDatabricks, "Workspace"}, azureTypeAKS: {portableKubernetes, "Cluster"}, azureTypeAgentPool: {portableKubernetes, "NodeGroup"}, + azureTypeSQL: {portableRelationalDB, "SqlServer"}, + azureTypeSQLMI: {portableRelationalDB, "SqlManagedInstance"}, + azureTypeMySQLFlex: {portableRelationalDB, "MySqlFlexibleServer"}, + azureTypePgFlex: {portableRelationalDB, "PostgresFlexibleServer"}, } // mapAzureType translates a fully-qualified Azure resource type to the diff --git a/server/azure/resourcegraph/portable_type_test.go b/server/azure/resourcegraph/portable_type_test.go index 8891296f..b132f5cb 100644 --- a/server/azure/resourcegraph/portable_type_test.go +++ b/server/azure/resourcegraph/portable_type_test.go @@ -14,6 +14,10 @@ func TestPortableToAzureType(t *testing.T) { {"databricks", "Workspace", "microsoft.databricks/workspaces"}, {"kubernetes", "Cluster", "microsoft.containerservice/managedclusters"}, {"kubernetes", "NodeGroup", "microsoft.containerservice/managedclusters/agentpools"}, + {"relationaldb", "SqlServer", "microsoft.sql/servers"}, + {"relationaldb", "SqlManagedInstance", "microsoft.sql/managedinstances"}, + {"relationaldb", "MySqlFlexibleServer", "microsoft.dbformysql/flexibleservers"}, + {"relationaldb", "PostgresFlexibleServer", "microsoft.dbforpostgresql/flexibleservers"}, } for _, c := range cases { diff --git a/server/azure/resourcegraph/sdk_test.go b/server/azure/resourcegraph/sdk_test.go index aeacc56f..077e3e88 100644 --- a/server/azure/resourcegraph/sdk_test.go +++ b/server/azure/resourcegraph/sdk_test.go @@ -24,6 +24,7 @@ import ( dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) type fakeCred struct{} @@ -461,3 +462,57 @@ func TestSDKResourceGraph_BugFixes(t *testing.T) { require.NotNil(t, out) }) } + +func TestSDKResourceGraph_SQLIndexing(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + if _, err := cloudP.SQL.CreateCluster(ctx, rdsdriver.ClusterConfig{ + ID: "srv1", MasterUsername: "admin", EngineVersion: "12.0", + }); err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if _, err := cloudP.SQL.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{ + Name: "mi1", SubnetID: "/subscriptions/123456789012/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vn/subnets/mi", + }); err != nil { + t.Fatalf("CreateManagedInstance: %v", err) + } + + srv := azureserver.New(azureserver.Drivers{ + SQL: cloudP.SQL, + MySQLFlex: cloudP.MySQLFlex, + PostgresFlex: cloudP.PostgresFlex, + ResourceDiscovery: cloudP.ResourceDiscovery, + SubscriptionID: "123456789012", + }) + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client := newResourceGraphClient(t, ts) + + t.Run("logical server and managed instance are indexed", func(t *testing.T) { + out, err := client.Resources(ctx, armresourcegraph.QueryRequest{ + Query: to.Ptr("Resources"), + }, nil) + require.NoError(t, err) + + data := out.Data.([]any) + assert.True(t, rowsHaveType(data, "microsoft.sql/servers"), "logical server must be indexed") + assert.True(t, rowsHaveType(data, "microsoft.sql/managedinstances"), "managed instance must be indexed") + }) + + t.Run("type filter narrows to the managed instance", func(t *testing.T) { + out, err := client.Resources(ctx, armresourcegraph.QueryRequest{ + Query: to.Ptr("Resources | where type =~ 'microsoft.sql/managedinstances' | project id, name, type"), + }, nil) + require.NoError(t, err) + + data := out.Data.([]any) + require.Len(t, data, 1) + + row := data[0].(map[string]any) + assert.Equal(t, "mi1", row["name"]) + assert.Equal(t, "microsoft.sql/managedinstances", row["type"]) + }) +} diff --git a/server/gcp/cloudasset/filter.go b/server/gcp/cloudasset/filter.go index 34e198c4..dbb31d5e 100644 --- a/server/gcp/cloudasset/filter.go +++ b/server/gcp/cloudasset/filter.go @@ -46,16 +46,18 @@ const ( atCloudFunctionV1 = "cloudfunctions.googleapis.com/CloudFunction" atGKECluster = "container.googleapis.com/Cluster" atGKENodePool = "container.googleapis.com/NodePool" + atCloudSQLInst = "sqladmin.googleapis.com/Instance" ) // Portable service identifiers as emitted by resourcediscovery walkers. const ( - portableCompute = "compute" - portableNetworking = "networking" - portableStorage = "storage" - portableDatabase = "database" - portableServerless = "serverless" - portableKubernetes = "kubernetes" + portableCompute = "compute" + portableNetworking = "networking" + portableStorage = "storage" + portableDatabase = "database" + portableServerless = "serverless" + portableKubernetes = "kubernetes" + portableRelationalDB = "relationaldb" ) // parsedFilter is the result of filter parsing — an engine Query plus @@ -228,57 +230,55 @@ func expandPortableService(svc string) []string { return []string{svc} } +// portableResourceType is a (service, type) pair in the portable vocabulary. +type portableResourceType struct{ service, typ string } + +// gcpAssetToPortable maps a fully-qualified GCP asset type to the portable +// (service, type) pair the engine uses. A map lookup rather than a switch keeps +// gocyclo under the gate as the pairs grow. +var gcpAssetToPortable = map[string]portableResourceType{ //nolint:gochecknoglobals // static lookup table + atComputeInstance: {portableCompute, "Instance"}, + atNetwork: {portableNetworking, "VPC"}, + atSubnetwork: {portableNetworking, "Subnet"}, + atFirewall: {portableNetworking, "SecurityGroup"}, + atStorageBucket: {portableStorage, "Bucket"}, + atFirestoreDB: {portableDatabase, "Table"}, + atFirestoreColl: {portableDatabase, "Table"}, + atCloudFunction: {portableServerless, "Function"}, + atCloudFunctionV1: {portableServerless, "Function"}, + atGKECluster: {portableKubernetes, "Cluster"}, + atGKENodePool: {portableKubernetes, "NodeGroup"}, + atCloudSQLInst: {portableRelationalDB, "SqlInstance"}, +} + +// portableToGCPAssetTypeMap is the inverse of gcpAssetToPortable. +var portableToGCPAssetTypeMap = map[string]string{ //nolint:gochecknoglobals // static lookup table + portableCompute + "/Instance": atComputeInstance, + portableNetworking + "/VPC": atNetwork, + portableNetworking + "/Subnet": atSubnetwork, + portableNetworking + "/SecurityGroup": atFirewall, + portableStorage + "/Bucket": atStorageBucket, + portableDatabase + "/Table": atFirestoreDB, + portableServerless + "/Function": atCloudFunction, + portableKubernetes + "/Cluster": atGKECluster, + portableKubernetes + "/NodeGroup": atGKENodePool, + portableRelationalDB + "/SqlInstance": atCloudSQLInst, +} + // mapGCPAssetType translates a fully-qualified GCP asset type // (compute.googleapis.com/Instance) to the portable (service, type) pair // the engine uses. Returns ("", "") for unmapped types. func mapGCPAssetType(assetType string) (service, typ string) { - switch assetType { - case atComputeInstance: - return portableCompute, "Instance" - case atNetwork: - return portableNetworking, "VPC" - case atSubnetwork: - return portableNetworking, "Subnet" - case atFirewall: - return portableNetworking, "SecurityGroup" - case atStorageBucket: - return portableStorage, "Bucket" - case atFirestoreDB, atFirestoreColl: - return portableDatabase, "Table" - case atCloudFunction, atCloudFunctionV1: - return portableServerless, "Function" - case atGKECluster: - return portableKubernetes, "Cluster" - case atGKENodePool: - return portableKubernetes, "NodeGroup" - default: - return "", "" - } + p := gcpAssetToPortable[assetType] + return p.service, p.typ } // portableToGCPAssetType is the inverse — turns the engine's (service, // type) pair into the canonical GCP assetType string the API emits. func portableToGCPAssetType(service, typ string) string { - switch service + "/" + typ { - case portableCompute + "/Instance": - return atComputeInstance - case portableNetworking + "/VPC": - return atNetwork - case portableNetworking + "/Subnet": - return atSubnetwork - case portableNetworking + "/SecurityGroup": - return atFirewall - case portableStorage + "/Bucket": - return atStorageBucket - case portableDatabase + "/Table": - return atFirestoreDB - case portableServerless + "/Function": - return atCloudFunction - case portableKubernetes + "/Cluster": - return atGKECluster - case portableKubernetes + "/NodeGroup": - return atGKENodePool - default: - return service + "/" + typ + if at, ok := portableToGCPAssetTypeMap[service+"/"+typ]; ok { + return at } + + return service + "/" + typ } diff --git a/server/gcp/cloudasset/filter_test.go b/server/gcp/cloudasset/filter_test.go index c4b0dba1..d880f641 100644 --- a/server/gcp/cloudasset/filter_test.go +++ b/server/gcp/cloudasset/filter_test.go @@ -164,6 +164,7 @@ func TestMapGCPAssetType(t *testing.T) { {"cloudfunctions.googleapis.com/Function", "serverless", "Function"}, {"container.googleapis.com/Cluster", "kubernetes", "Cluster"}, {"container.googleapis.com/NodePool", "kubernetes", "NodeGroup"}, + {"sqladmin.googleapis.com/Instance", "relationaldb", "SqlInstance"}, {"unknown.googleapis.com/Widget", "", ""}, } @@ -191,6 +192,7 @@ func TestPortableToGCPAssetType_Roundtrip(t *testing.T) { {"serverless", "Function", "cloudfunctions.googleapis.com/Function"}, {"kubernetes", "Cluster", "container.googleapis.com/Cluster"}, {"kubernetes", "NodeGroup", "container.googleapis.com/NodePool"}, + {"relationaldb", "SqlInstance", "sqladmin.googleapis.com/Instance"}, } for _, c := range cases { diff --git a/server/gcp/cloudsql/catalog.go b/server/gcp/cloudsql/catalog.go new file mode 100644 index 00000000..3f250421 --- /dev/null +++ b/server/gcp/cloudsql/catalog.go @@ -0,0 +1,87 @@ +package cloudsql + +import "net/http" + +// Cloud SQL exposes two static reference catalogs — machine tiers and database +// flags. Real Cloud SQL returns hundreds of region-specific entries; the mock +// serves a small representative set so SDK clients that enumerate them get a +// well-formed, non-empty response. + +type tier struct { + Kind string `json:"kind"` + Tier string `json:"tier"` + RAM int64 `json:"RAM,string"` + DiskQuota int64 `json:"DiskQuota,string"` + Region []string `json:"region"` +} + +type tiersList struct { + Kind string `json:"kind"` + Items []tier `json:"items"` +} + +type flag struct { + Kind string `json:"kind"` + Name string `json:"name"` + Type string `json:"type"` + AppliesTo []string `json:"appliesTo"` + AllowedStringValues []string `json:"allowedStringValues,omitempty"` + MinValue int64 `json:"minValue,omitempty,string"` + MaxValue int64 `json:"maxValue,omitempty,string"` + RequiresRestart bool `json:"requiresRestart"` +} + +type flagsList struct { + Kind string `json:"kind"` + Items []flag `json:"items"` +} + +const gib = 1 << 30 + +var catalogRegions = []string{"us-central1", "us-east1", "europe-west1"} //nolint:gochecknoglobals // static catalog + +// serveTiers handles GET /v1/projects/{p}/tiers. +func serveTiers(w http.ResponseWriter, r *http.Request, _ *sqlPath) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + writeJSON(w, http.StatusOK, tiersList{ + Kind: "sql#tiersList", + Items: []tier{ + {Kind: "sql#tier", Tier: "db-f1-micro", RAM: 614 * (1 << 20), DiskQuota: 3072 * gib, Region: catalogRegions}, + {Kind: "sql#tier", Tier: "db-g1-small", RAM: 1740 * (1 << 20), DiskQuota: 3072 * gib, Region: catalogRegions}, + {Kind: "sql#tier", Tier: "db-custom-1-3840", RAM: 3840 * (1 << 20), DiskQuota: 65536 * gib, Region: catalogRegions}, + {Kind: "sql#tier", Tier: "db-custom-2-7680", RAM: 7680 * (1 << 20), DiskQuota: 65536 * gib, Region: catalogRegions}, + }, + }) +} + +// serveFlags handles GET /v1/flags. +func serveFlags(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + mysqlPg := []string{"MYSQL_8_0", "POSTGRES_15"} + + writeJSON(w, http.StatusOK, flagsList{ + Kind: "sql#flagsList", + Items: []flag{ + { + Kind: "sql#flag", Name: "max_connections", Type: "INTEGER", + AppliesTo: mysqlPg, MinValue: 1, MaxValue: 262143, RequiresRestart: true, + }, + { + Kind: "sql#flag", Name: "slow_query_log", Type: "STRING", + AppliesTo: []string{"MYSQL_8_0"}, AllowedStringValues: []string{"on", "off"}, RequiresRestart: false, + }, + { + Kind: "sql#flag", Name: "log_min_duration_statement", Type: "INTEGER", + AppliesTo: []string{"POSTGRES_15"}, MinValue: -1, MaxValue: 2147483647, RequiresRestart: false, + }, + }, + }) +} diff --git a/server/gcp/cloudsql/handler.go b/server/gcp/cloudsql/handler.go index 16290f7c..e727d81d 100644 --- a/server/gcp/cloudsql/handler.go +++ b/server/gcp/cloudsql/handler.go @@ -38,14 +38,30 @@ import ( const ( pathPrefix = "/v1/projects/" + pathFlags = "/v1/flags" contentTypeJSON = "application/json" maxBodyBytes = 1 << 20 resourceInstances = "instances" resourceOperations = "operations" resourceBackupRuns = "backupRuns" + resourceDatabases = "databases" + resourceUsers = "users" + resourceSslCerts = "sslCerts" + resourceTiers = "tiers" ) +// isSubResource reports whether seg is an instance-scoped sub-collection; any +// other trailing segment is treated as an action (restart, clone, …). +func isSubResource(seg string) bool { + switch seg { + case resourceBackupRuns, resourceDatabases, resourceUsers, resourceSslCerts: + return true + default: + return false + } +} + // Handler serves Cloud SQL Admin REST requests against a relationaldb driver. type Handler struct { db rdsdriver.RelationalDB @@ -56,11 +72,15 @@ func New(db rdsdriver.RelationalDB) *Handler { return &Handler{db: db} } -// Matches accepts /v1/projects/{p}/{instances|operations}/... paths. -// Other resource types under /v1/projects/ (locations, topics, subscriptions, -// databases) belong to Cloud Functions, Pub/Sub, or Firestore respectively -// and must fall through. +// Matches accepts /v1/projects/{p}/{instances|operations|tiers}/... paths plus +// the project-less /v1/flags catalog. Other resource types under /v1/projects/ +// (locations, topics, subscriptions, databases) belong to Cloud Functions, +// Pub/Sub, or Firestore respectively and must fall through. func (*Handler) Matches(r *http.Request) bool { + if r.URL.Path == pathFlags { + return true + } + if !strings.HasPrefix(r.URL.Path, pathPrefix) { return false } @@ -74,7 +94,7 @@ func (*Handler) Matches(r *http.Request) bool { } switch parts[idxResource] { - case resourceInstances, resourceOperations: + case resourceInstances, resourceOperations, resourceTiers: return true } @@ -83,11 +103,11 @@ func (*Handler) Matches(r *http.Request) bool { // path components parsed out of the URL. // -// /sql/v1beta4/projects/{p}/instances -// /sql/v1beta4/projects/{p}/instances/{i} -// /sql/v1beta4/projects/{p}/instances/{i}/{action} -// /sql/v1beta4/projects/{p}/instances/{i}/backupRuns[/{id}] -// /sql/v1beta4/projects/{p}/operations/{op} +// /v1/projects/{p}/instances +// /v1/projects/{p}/instances/{i} +// /v1/projects/{p}/instances/{i}/{action} +// /v1/projects/{p}/instances/{i}/backupRuns[/{id}] +// /v1/projects/{p}/operations/{op} type sqlPath struct { project string resource string // "instances" or "operations" @@ -123,7 +143,7 @@ func parsePath(urlPath string) (sqlPath, bool) { if len(parts) > idxSubResource { // {action} OR {subResource} seg := parts[idxSubResource] - if seg == resourceBackupRuns { + if isSubResource(seg) { out.subResource = seg } else { out.action = seg @@ -139,6 +159,12 @@ func parsePath(urlPath string) (sqlPath, bool) { // ServeHTTP routes the parsed path to the matching operation. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // /v1/flags is project-less, so it bypasses the project path parser. + if r.URL.Path == pathFlags { + serveFlags(w, r) + return + } + p, ok := parsePath(r.URL.Path) if !ok { writeError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "malformed path") @@ -150,19 +176,31 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveOperation(w, r, &p) case resourceInstances: h.serveInstancesRoute(w, r, &p) + case resourceTiers: + serveTiers(w, r, &p) default: writeError(w, http.StatusNotFound, "NOT_FOUND", "unsupported resource: "+p.resource) } } func (h *Handler) serveInstancesRoute(w http.ResponseWriter, r *http.Request, p *sqlPath) { - // Sub-resource: backup runs. - if p.subResource == resourceBackupRuns { + // Instance-scoped sub-collections. + switch p.subResource { + case resourceBackupRuns: h.serveBackupRunsRoute(w, r, p) return + case resourceDatabases: + h.serveDatabasesRoute(w, r, p) + return + case resourceUsers: + h.serveUsersRoute(w, r, p) + return + case resourceSslCerts: + h.serveSslCertsRoute(w, r, p) + return } - // Action on an instance: restart, restoreBackup. + // Action on an instance: restart, restoreBackup, clone, failover, replicas. if p.action != "" { h.serveInstanceAction(w, r, p) return @@ -213,6 +251,16 @@ func (h *Handler) serveInstanceAction(w http.ResponseWriter, r *http.Request, p h.restartInstance(w, r, p) case "restoreBackup": h.restoreInstance(w, r, p) + case "clone": + h.cloneInstance(w, r, p) + case "failover": + h.failoverInstance(w, r, p) + case "promoteReplica": + h.promoteReplica(w, r, p) + case "startReplica": + h.startReplica(w, r, p) + case "stopReplica": + h.stopReplica(w, r, p) default: writeError(w, http.StatusNotFound, "NOT_FOUND", "unsupported action: "+p.action) } diff --git a/server/gcp/cloudsql/operations.go b/server/gcp/cloudsql/operations.go index 66f717fb..2f38e845 100644 --- a/server/gcp/cloudsql/operations.go +++ b/server/gcp/cloudsql/operations.go @@ -2,9 +2,8 @@ package cloudsql import ( "net/http" - "strconv" - "time" + cerrors "github.com/stackshy/cloudemu/v2/errors" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -13,10 +12,11 @@ import ( // activationPolicy live under settings. func instanceFromBody(body *sqlInstance) rdsdriver.InstanceConfig { cfg := rdsdriver.InstanceConfig{ - ID: body.Name, - Engine: body.DatabaseVersion, - AvailabilityZone: body.Region, - MasterUsername: body.RootPassword, // SDKs use rootPassword on insert. + ID: body.Name, + Engine: body.DatabaseVersion, + AvailabilityZone: body.Region, + MasterUsername: body.RootPassword, // SDKs use rootPassword on insert. + MasterInstanceName: body.MasterInstanceName, } if body.Settings != nil { @@ -70,6 +70,11 @@ func (h *Handler) getInstance(w http.ResponseWriter, r *http.Request, p *sqlPath return } + if len(insts) == 0 { + writeErr(w, cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", p.name)) + return + } + writeJSON(w, http.StatusOK, toSQLInstance(&insts[0], p.project)) } @@ -148,13 +153,16 @@ func (h *Handler) restoreInstance(w http.ResponseWriter, r *http.Request, p *sql return } - // p.name is the *target* instance to restore into. - input := rdsdriver.RestoreInstanceInput{ - NewInstanceID: p.name, - SnapshotID: body.RestoreBackupContext.BackupRunID, + // p.name is the *target* instance to restore into. Cloud SQL restores the + // backup in place onto the existing instance rather than provisioning a new + // one, so prefer the in-place BackupRestorer capability. + restorer, ok := h.db.(rdsdriver.BackupRestorer) + if !ok { + writeErr(w, cerrors.New(cerrors.FailedPrecondition, "backend does not support restoreBackup")) + return } - if _, err := h.db.RestoreInstanceFromSnapshot(r.Context(), input); err != nil { + if _, err := restorer.RestoreBackup(r.Context(), p.name, body.RestoreBackupContext.BackupRunID); err != nil { writeErr(w, err) return } @@ -165,15 +173,9 @@ func (h *Handler) restoreInstance(w http.ResponseWriter, r *http.Request, p *sql } func (h *Handler) insertBackupRun(w http.ResponseWriter, r *http.Request, p *sqlPath) { - // SDK lets the caller omit ID and we generate one. - id := strconv.FormatInt(time.Now().UnixNano(), 10) - - cfg := rdsdriver.SnapshotConfig{ - ID: id, - InstanceID: p.name, - } - - snap, err := h.db.CreateSnapshot(r.Context(), cfg) + // The backup-run ID is generated deterministically by the mock (clock + + // counter); leave it empty here. + snap, err := h.db.CreateSnapshot(r.Context(), rdsdriver.SnapshotConfig{InstanceID: p.name}) if err != nil { writeErr(w, err) return diff --git a/server/gcp/cloudsql/sdk_roundtrip_test.go b/server/gcp/cloudsql/sdk_roundtrip_test.go index 88403218..c147f3fb 100644 --- a/server/gcp/cloudsql/sdk_roundtrip_test.go +++ b/server/gcp/cloudsql/sdk_roundtrip_test.go @@ -166,6 +166,13 @@ func TestSDKCloudSQLBackupRunsAndRestore(t *testing.T) { t.Fatal("expected TargetId from BackupRuns.Insert response") } + // The insert returns an operation; fetch it by name (Operations.Get). + if op.Name != "" { + if _, err := svc.Operations.Get(project, op.Name).Context(ctx).Do(); err != nil { + t.Fatalf("Operations.Get: %v", err) + } + } + list, err := svc.BackupRuns.List(project, "src").Context(ctx).Do() if err != nil { t.Fatalf("BackupRuns.List: %v", err) @@ -175,6 +182,15 @@ func TestSDKCloudSQLBackupRunsAndRestore(t *testing.T) { t.Fatalf("got %d backup runs, want 1", len(list.Items)) } + parsedForGet, err := strconvAtoi64(backupID) + if err != nil { + t.Fatalf("backup id %q not numeric: %v", backupID, err) + } + + if _, err := svc.BackupRuns.Get(project, "src", parsedForGet).Context(ctx).Do(); err != nil { + t.Fatalf("BackupRuns.Get: %v", err) + } + // Create the target instance for restore (Cloud SQL: target must exist). if _, err := svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ Name: "target", @@ -186,22 +202,13 @@ func TestSDKCloudSQLBackupRunsAndRestore(t *testing.T) { } // RestoreBackup is special: target instance is the URL path; backup run - // id is in the body. Real Cloud SQL replaces the target's data with the - // backup; the mock just verifies the operation completes successfully. - // We use the existing target instance — RestoreBackup is supposed to be - // applied to an *existing* instance. + // id is in the body. Cloud SQL replaces the *existing* target's data with + // the backup in place, so the target must still exist when we call it. parsedID, err := strconvAtoi64(backupID) if err != nil { t.Fatalf("backup id %q not numeric: %v", backupID, err) } - // Delete the target so the restore (which uses NewInstanceID for our - // mock) doesn't conflict — RestoreBackup conceptually replaces the - // target's data. - if _, err := svc.Instances.Delete(project, "target").Context(ctx).Do(); err != nil { - t.Fatalf("Delete target before restore: %v", err) - } - if _, err := svc.Instances.RestoreBackup(project, "target", &sqladmin.InstancesRestoreBackupRequest{ RestoreBackupContext: &sqladmin.RestoreBackupContext{ diff --git a/server/gcp/cloudsql/subresources.go b/server/gcp/cloudsql/subresources.go new file mode 100644 index 00000000..dfbb52f8 --- /dev/null +++ b/server/gcp/cloudsql/subresources.go @@ -0,0 +1,487 @@ +package cloudsql + +import ( + "net/http" + + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +//nolint:gosec // placeholder PEM returned to SDK round-trips; not a real key. +const mockKeyPEM = "-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----" + +// ---- wire types ---- + +type database struct { + Kind string `json:"kind"` + Name string `json:"name"` + Instance string `json:"instance"` + Project string `json:"project,omitempty"` + Charset string `json:"charset,omitempty"` + Collation string `json:"collation,omitempty"` + SelfLink string `json:"selfLink,omitempty"` +} + +type databasesList struct { + Kind string `json:"kind"` + Items []database `json:"items"` +} + +type user struct { + Kind string `json:"kind"` + Name string `json:"name"` + Host string `json:"host,omitempty"` + Instance string `json:"instance,omitempty"` + Project string `json:"project,omitempty"` + Password string `json:"password,omitempty"` +} + +type usersList struct { + Kind string `json:"kind"` + Items []user `json:"items"` +} + +type sslCert struct { + Kind string `json:"kind"` + CommonName string `json:"commonName"` + Sha1Fingerprint string `json:"sha1Fingerprint"` + CertSerialNumber string `json:"certSerialNumber,omitempty"` + Cert string `json:"cert,omitempty"` + Instance string `json:"instance,omitempty"` + CreateTime string `json:"createTime,omitempty"` +} + +type sslCertsList struct { + Kind string `json:"kind"` + Items []sslCert `json:"items"` +} + +type sslCertInsertResponse struct { + Kind string `json:"kind"` + ClientCert clientCert `json:"clientCert"` + Operation operation `json:"operation"` +} + +type clientCert struct { + CertInfo sslCert `json:"certInfo"` + CertPrivateKey string `json:"certPrivateKey"` +} + +type cloneRequest struct { + CloneContext struct { + DestinationInstanceName string `json:"destinationInstanceName"` + } `json:"cloneContext"` +} + +// ---- capability accessors ---- + +func (h *Handler) databasesCap() (rdsdriver.Databases, bool) { + c, ok := h.db.(rdsdriver.Databases) + return c, ok +} + +func (h *Handler) usersCap() (rdsdriver.Users, bool) { + c, ok := h.db.(rdsdriver.Users) + return c, ok +} + +func (h *Handler) sslCertsCap() (rdsdriver.SslCerts, bool) { + c, ok := h.db.(rdsdriver.SslCerts) + return c, ok +} + +func writeUnsupported(w http.ResponseWriter, what string) { + writeError(w, http.StatusBadRequest, "OPERATION_NOT_SUPPORTED", what+" is not supported by this driver") +} + +// ---- Databases ---- + +//nolint:dupl // mirrors the sibling sub-resource route by design. +func (h *Handler) serveDatabasesRoute(w http.ResponseWriter, r *http.Request, p *sqlPath) { + db, ok := h.databasesCap() + if !ok { + writeUnsupported(w, "databases") + return + } + + if p.subName == "" { + switch r.Method { + case http.MethodPost: + h.insertDatabase(w, r, p, db) + case http.MethodGet: + h.listDatabases2(w, r, p, db) + default: + writeMethodNotAllowed(w) + } + + return + } + + switch r.Method { + case http.MethodGet: + h.getDatabase(w, r, p, db) + case http.MethodDelete: + if err := db.DeleteDatabase(r.Context(), p.name, p.subName); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "delete-db", "DELETE_DATABASE", "instances", p.name)) + default: + writeMethodNotAllowed(w) + } +} + +//nolint:dupl // mirrors the sibling insert handler by design. +func (*Handler) insertDatabase(w http.ResponseWriter, r *http.Request, p *sqlPath, db rdsdriver.Databases) { + var body database + if !decodeJSON(w, r, &body) { + return + } + + _, err := db.CreateDatabase(r.Context(), rdsdriver.DatabaseConfig{ + Server: p.name, Name: body.Name, Charset: body.Charset, Collation: body.Collation, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "insert-db", "CREATE_DATABASE", "instances", p.name)) +} + +func (*Handler) getDatabase(w http.ResponseWriter, r *http.Request, p *sqlPath, db rdsdriver.Databases) { + out, err := db.GetDatabase(r.Context(), p.name, p.subName) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toWireDatabase(out, p.project)) +} + +func (*Handler) listDatabases2(w http.ResponseWriter, r *http.Request, p *sqlPath, db rdsdriver.Databases) { + items, err := db.ListDatabases(r.Context(), p.name) + if err != nil { + writeErr(w, err) + return + } + + out := make([]database, 0, len(items)) + for i := range items { + out = append(out, toWireDatabase(&items[i], p.project)) + } + + writeJSON(w, http.StatusOK, databasesList{Kind: "sql#databasesList", Items: out}) +} + +func toWireDatabase(d *rdsdriver.Database, project string) database { + return database{ + Kind: "sql#database", + Name: d.Name, + Instance: d.Server, + Project: project, + Charset: d.Charset, + Collation: d.Collation, + SelfLink: "/sql/v1beta4/projects/" + project + "/instances/" + d.Server + "/databases/" + d.Name, + } +} + +// ---- Users ---- + +// serveUsersRoute handles the Cloud SQL user quirk: Get uses /users/{name} +// while Delete and Update act on the /users collection with a ?name= query +// parameter. +func (h *Handler) serveUsersRoute(w http.ResponseWriter, r *http.Request, p *sqlPath) { + u, ok := h.usersCap() + if !ok { + writeUnsupported(w, "users") + return + } + + if p.subName != "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + h.getUser(w, r, p, u) + + return + } + + switch r.Method { + case http.MethodPost: + h.insertUser(w, r, p, u) + case http.MethodGet: + h.listUsers(w, r, p, u) + case http.MethodPut: + h.updateUser(w, r, p, u) + case http.MethodDelete: + h.deleteUser(w, r, p, u) + default: + writeMethodNotAllowed(w) + } +} + +//nolint:dupl // mirrors the sibling insert handler by design. +func (*Handler) insertUser(w http.ResponseWriter, r *http.Request, p *sqlPath, u rdsdriver.Users) { + var body user + if !decodeJSON(w, r, &body) { + return + } + + _, err := u.CreateUser(r.Context(), rdsdriver.UserConfig{ + Instance: p.name, Name: body.Name, Host: body.Host, Password: body.Password, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "insert-user", "CREATE_USER", "instances", p.name)) +} + +func (*Handler) getUser(w http.ResponseWriter, r *http.Request, p *sqlPath, u rdsdriver.Users) { + out, err := u.GetUser(r.Context(), p.name, p.subName) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toWireUser(out, p.project)) +} + +func (*Handler) listUsers(w http.ResponseWriter, r *http.Request, p *sqlPath, u rdsdriver.Users) { + items, err := u.ListUsers(r.Context(), p.name) + if err != nil { + writeErr(w, err) + return + } + + out := make([]user, 0, len(items)) + for i := range items { + out = append(out, toWireUser(&items[i], p.project)) + } + + writeJSON(w, http.StatusOK, usersList{Kind: "sql#usersList", Items: out}) +} + +func (*Handler) updateUser(w http.ResponseWriter, r *http.Request, p *sqlPath, u rdsdriver.Users) { + var body user + if !decodeJSON(w, r, &body) { + return + } + + name := r.URL.Query().Get("name") + + _, err := u.UpdateUser(r.Context(), rdsdriver.UserConfig{ + Instance: p.name, Name: name, Host: r.URL.Query().Get("host"), Password: body.Password, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "update-user", "UPDATE_USER", "instances", p.name)) +} + +func (*Handler) deleteUser(w http.ResponseWriter, r *http.Request, p *sqlPath, u rdsdriver.Users) { + name := r.URL.Query().Get("name") + + if err := u.DeleteUser(r.Context(), p.name, name); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "delete-user", "DELETE_USER", "instances", p.name)) +} + +func toWireUser(u *rdsdriver.User, project string) user { + return user{Kind: "sql#user", Name: u.Name, Host: u.Host, Instance: u.Instance, Project: project} +} + +// ---- SSL certs ---- + +//nolint:dupl // mirrors the sibling sub-resource route by design. +func (h *Handler) serveSslCertsRoute(w http.ResponseWriter, r *http.Request, p *sqlPath) { + sc, ok := h.sslCertsCap() + if !ok { + writeUnsupported(w, "sslCerts") + return + } + + if p.subName == "" { + switch r.Method { + case http.MethodPost: + h.insertSslCert(w, r, p, sc) + case http.MethodGet: + h.listSslCerts(w, r, p, sc) + default: + writeMethodNotAllowed(w) + } + + return + } + + switch r.Method { + case http.MethodGet: + h.getSslCert(w, r, p, sc) + case http.MethodDelete: + if err := sc.DeleteSslCert(r.Context(), p.name, p.subName); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "delete-cert", "DELETE_SSL_CERT", "instances", p.name)) + default: + writeMethodNotAllowed(w) + } +} + +func (*Handler) insertSslCert(w http.ResponseWriter, r *http.Request, p *sqlPath, sc rdsdriver.SslCerts) { + var body struct { + CommonName string `json:"commonName"` + } + + if !decodeJSON(w, r, &body) { + return + } + + out, err := sc.CreateSslCert(r.Context(), rdsdriver.SslCertConfig{Instance: p.name, CommonName: body.CommonName}) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, sslCertInsertResponse{ + Kind: "sql#sslCertsInsert", + ClientCert: clientCert{CertInfo: toWireSslCert(out), CertPrivateKey: mockKeyPEM}, + Operation: doneOperationWithTarget(p.project, "insert-cert", "CREATE_SSL_CERT", "instances", p.name), + }) +} + +func (*Handler) getSslCert(w http.ResponseWriter, r *http.Request, p *sqlPath, sc rdsdriver.SslCerts) { + out, err := sc.GetSslCert(r.Context(), p.name, p.subName) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toWireSslCert(out)) +} + +func (*Handler) listSslCerts(w http.ResponseWriter, r *http.Request, p *sqlPath, sc rdsdriver.SslCerts) { + items, err := sc.ListSslCerts(r.Context(), p.name) + if err != nil { + writeErr(w, err) + return + } + + out := make([]sslCert, 0, len(items)) + for i := range items { + out = append(out, toWireSslCert(&items[i])) + } + + writeJSON(w, http.StatusOK, sslCertsList{Kind: "sql#sslCertsList", Items: out}) +} + +func toWireSslCert(c *rdsdriver.SslCert) sslCert { + return sslCert{ + Kind: "sql#sslCert", + CommonName: c.CommonName, + Sha1Fingerprint: c.Sha1Fingerprint, + CertSerialNumber: c.SerialNumber, + Cert: c.Cert, + Instance: c.Instance, + } +} + +// ---- instance actions: clone, failover, replicas ---- + +func (h *Handler) cloneInstance(w http.ResponseWriter, r *http.Request, p *sqlPath) { + c, ok := h.db.(rdsdriver.Clonable) + if !ok { + writeUnsupported(w, "clone") + return + } + + var body cloneRequest + if !decodeJSON(w, r, &body) { + return + } + + if _, err := c.CloneInstance(r.Context(), p.name, body.CloneContext.DestinationInstanceName); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, + doneOperationWithTarget(p.project, "clone", "CLONE", "instances", body.CloneContext.DestinationInstanceName)) +} + +func (h *Handler) failoverInstance(w http.ResponseWriter, r *http.Request, p *sqlPath) { + f, ok := h.db.(rdsdriver.Failover) + if !ok { + writeUnsupported(w, "failover") + return + } + + if err := f.FailoverInstance(r.Context(), p.name); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "failover", "FAILOVER", "instances", p.name)) +} + +func (h *Handler) promoteReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) { + pr, ok := h.db.(rdsdriver.ReplicaPromotion) + if !ok { + writeUnsupported(w, "promoteReplica") + return + } + + if err := pr.PromoteReplica(r.Context(), p.name); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "promote", "PROMOTE_REPLICA", "instances", p.name)) +} + +// startReplica / stopReplica start and stop replication on a read replica. +// Unlike Start/StopInstance they do not change the RUNNABLE state (a replica +// stays running), and they require the target to actually be a replica — +// matching real Cloud SQL, which errors otherwise. +func (h *Handler) startReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) { + if !h.requireReplica(w, r, p) { + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "start-replica", "START_REPLICA", "instances", p.name)) +} + +// requireReplica writes an error and returns false unless p.name is an existing +// read replica (has a master). +func (h *Handler) requireReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) bool { + insts, err := h.db.DescribeInstances(r.Context(), []string{p.name}) + if err != nil { + writeErr(w, err) + return false + } + + if insts[0].ReadReplicaSource == "" { + writeError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "instance is not a read replica: "+p.name) + return false + } + + return true +} + +func (h *Handler) stopReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) { + if !h.requireReplica(w, r, p) { + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "stop-replica", "STOP_REPLICA", "instances", p.name)) +} diff --git a/server/gcp/cloudsql/subresources_sdk_test.go b/server/gcp/cloudsql/subresources_sdk_test.go new file mode 100644 index 00000000..8ff537ca --- /dev/null +++ b/server/gcp/cloudsql/subresources_sdk_test.go @@ -0,0 +1,285 @@ +package cloudsql_test + +import ( + "context" + "testing" + + sqladmin "google.golang.org/api/sqladmin/v1" +) + +func mustCreateInstance(t *testing.T, svc *sqladmin.Service, project, name string) { + t.Helper() + + _, err := svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ + Name: name, + DatabaseVersion: "POSTGRES_15", + Region: "us-central1", + Settings: &sqladmin.Settings{Tier: "db-custom-2-8192", DataDiskSizeGb: 50}, + }).Context(context.Background()).Do() + if err != nil { + t.Fatalf("Instances.Insert %q: %v", name, err) + } +} + +func TestSDKCloudSQLDatabases(t *testing.T) { + svc, project := newSDKClient(t) + ctx := context.Background() + mustCreateInstance(t, svc, project, "pg") + + if _, err := svc.Databases.Insert(project, "pg", &sqladmin.Database{ + Name: "appdb", Charset: "UTF8", Collation: "en_US.UTF8", + }).Context(ctx).Do(); err != nil { + t.Fatalf("Databases.Insert: %v", err) + } + + got, err := svc.Databases.Get(project, "pg", "appdb").Context(ctx).Do() + if err != nil { + t.Fatalf("Databases.Get: %v", err) + } + + if got.Charset != "UTF8" { + t.Fatalf("charset: got %q, want UTF8", got.Charset) + } + + list, err := svc.Databases.List(project, "pg").Context(ctx).Do() + if err != nil { + t.Fatalf("Databases.List: %v", err) + } + + if len(list.Items) != 1 { + t.Fatalf("got %d databases, want 1", len(list.Items)) + } + + if _, err := svc.Databases.Delete(project, "pg", "appdb").Context(ctx).Do(); err != nil { + t.Fatalf("Databases.Delete: %v", err) + } + + if _, err := svc.Databases.Get(project, "pg", "appdb").Context(ctx).Do(); err == nil { + t.Fatal("expected error after database delete") + } +} + +func TestSDKCloudSQLUsers(t *testing.T) { + svc, project := newSDKClient(t) + ctx := context.Background() + mustCreateInstance(t, svc, project, "pg") + + if _, err := svc.Users.Insert(project, "pg", &sqladmin.User{ + Name: "appuser", Host: "%", + }).Context(ctx).Do(); err != nil { + t.Fatalf("Users.Insert: %v", err) + } + + got, err := svc.Users.Get(project, "pg", "appuser").Context(ctx).Do() + if err != nil { + t.Fatalf("Users.Get: %v", err) + } + + if got.Name != "appuser" { + t.Fatalf("name: got %q, want appuser", got.Name) + } + + list, err := svc.Users.List(project, "pg").Context(ctx).Do() + if err != nil { + t.Fatalf("Users.List: %v", err) + } + + if len(list.Items) != 1 { + t.Fatalf("got %d users, want 1", len(list.Items)) + } + + // Update the user's password (Cloud SQL users.update). + if _, err := svc.Users.Update(project, "pg", &sqladmin.User{ + Name: "appuser", Host: "%", Password: "newpass", + }).Name("appuser").Context(ctx).Do(); err != nil { + t.Fatalf("Users.Update: %v", err) + } + + if _, err := svc.Users.Delete(project, "pg").Name("appuser").Context(ctx).Do(); err != nil { + t.Fatalf("Users.Delete: %v", err) + } + + if _, err := svc.Users.Get(project, "pg", "appuser").Context(ctx).Do(); err == nil { + t.Fatal("expected error after user delete") + } +} + +func TestSDKCloudSQLSslCerts(t *testing.T) { + svc, project := newSDKClient(t) + ctx := context.Background() + mustCreateInstance(t, svc, project, "pg") + + resp, err := svc.SslCerts.Insert(project, "pg", &sqladmin.SslCertsInsertRequest{ + CommonName: "client-1", + }).Context(ctx).Do() + if err != nil { + t.Fatalf("SslCerts.Insert: %v", err) + } + + if resp.ClientCert == nil || resp.ClientCert.CertInfo == nil || resp.ClientCert.CertInfo.Sha1Fingerprint == "" { + t.Fatalf("expected client cert with fingerprint, got %+v", resp.ClientCert) + } + + fp := resp.ClientCert.CertInfo.Sha1Fingerprint + + got, err := svc.SslCerts.Get(project, "pg", fp).Context(ctx).Do() + if err != nil { + t.Fatalf("SslCerts.Get: %v", err) + } + + if got.CommonName != "client-1" { + t.Fatalf("commonName: got %q, want client-1", got.CommonName) + } + + list, err := svc.SslCerts.List(project, "pg").Context(ctx).Do() + if err != nil { + t.Fatalf("SslCerts.List: %v", err) + } + + if len(list.Items) != 1 { + t.Fatalf("got %d certs, want 1", len(list.Items)) + } + + if _, err := svc.SslCerts.Delete(project, "pg", fp).Context(ctx).Do(); err != nil { + t.Fatalf("SslCerts.Delete: %v", err) + } +} + +func TestSDKCloudSQLInstanceActions(t *testing.T) { + svc, project := newSDKClient(t) + ctx := context.Background() + mustCreateInstance(t, svc, project, "pg") + + // Clone produces an independent instance. + if _, err := svc.Instances.Clone(project, "pg", &sqladmin.InstancesCloneRequest{ + CloneContext: &sqladmin.CloneContext{DestinationInstanceName: "pg-clone"}, + }).Context(ctx).Do(); err != nil { + t.Fatalf("Instances.Clone: %v", err) + } + + if _, err := svc.Instances.Get(project, "pg-clone").Context(ctx).Do(); err != nil { + t.Fatalf("Get clone: %v", err) + } + + // Failover is valid on the primary. + if _, err := svc.Instances.Failover(project, "pg", &sqladmin.InstancesFailoverRequest{}).Context(ctx).Do(); err != nil { + t.Fatalf("Instances.Failover: %v", err) + } + + // A read replica is created via a normal insert with masterInstanceName. + if _, err := svc.Instances.Insert(project, &sqladmin.DatabaseInstance{ + Name: "pg-replica", + DatabaseVersion: "POSTGRES_15", + Region: "us-central1", + MasterInstanceName: "pg", + Settings: &sqladmin.Settings{Tier: "db-custom-2-8192"}, + }).Context(ctx).Do(); err != nil { + t.Fatalf("replica Insert: %v", err) + } + + // The primary now lists the replica; the replica records its master. + primary, err := svc.Instances.Get(project, "pg").Context(ctx).Do() + if err != nil { + t.Fatalf("Get primary: %v", err) + } + + if len(primary.ReplicaNames) != 1 || primary.ReplicaNames[0] != "pg-replica" { + t.Fatalf("primary replicaNames = %v, want [pg-replica]", primary.ReplicaNames) + } + + // Replica lifecycle actions require an actual replica. + if _, err := svc.Instances.StopReplica(project, "pg-replica").Context(ctx).Do(); err != nil { + t.Fatalf("Instances.StopReplica: %v", err) + } + + if _, err := svc.Instances.StartReplica(project, "pg-replica").Context(ctx).Do(); err != nil { + t.Fatalf("Instances.StartReplica: %v", err) + } + + // Replica ops on a non-replica are rejected. + if _, err := svc.Instances.PromoteReplica(project, "pg-clone").Context(ctx).Do(); err == nil { + t.Fatal("PromoteReplica on a non-replica: expected error") + } + + // Promote detaches the replica from its master. + if _, err := svc.Instances.PromoteReplica(project, "pg-replica").Context(ctx).Do(); err != nil { + t.Fatalf("Instances.PromoteReplica: %v", err) + } + + promoted, err := svc.Instances.Get(project, "pg-replica").Context(ctx).Do() + if err != nil { + t.Fatalf("Get promoted: %v", err) + } + + if promoted.MasterInstanceName != "" { + t.Fatalf("promoted replica still has master %q", promoted.MasterInstanceName) + } +} + +func TestSDKCloudSQLTiersAndFlags(t *testing.T) { + svc, project := newSDKClient(t) + ctx := context.Background() + + tiers, err := svc.Tiers.List(project).Context(ctx).Do() + if err != nil { + t.Fatalf("Tiers.List: %v", err) + } + + if len(tiers.Items) == 0 { + t.Fatal("expected a non-empty tier catalog") + } + + if tiers.Items[0].Tier == "" || tiers.Items[0].RAM == 0 { + t.Fatalf("tier not populated: %+v", tiers.Items[0]) + } + + flags, err := svc.Flags.List().Context(ctx).Do() + if err != nil { + t.Fatalf("Flags.List: %v", err) + } + + if len(flags.Items) == 0 { + t.Fatal("expected a non-empty flag catalog") + } + + var sawMaxConns bool + for _, f := range flags.Items { + if f.Name == "max_connections" { + sawMaxConns = true + } + } + + if !sawMaxConns { + t.Error("expected max_connections in the flag catalog") + } +} + +func TestSDKCloudSQLErrorPaths(t *testing.T) { + svc, project := newSDKClient(t) + ctx := context.Background() + + // Operations on a nonexistent instance surface errors (handler writeErr). + if _, err := svc.Instances.Delete(project, "ghost").Context(ctx).Do(); err == nil { + t.Error("Delete missing instance: expected error") + } + + if _, err := svc.Instances.Restart(project, "ghost").Context(ctx).Do(); err == nil { + t.Error("Restart missing instance: expected error") + } + + if _, err := svc.Instances.Get(project, "ghost").Context(ctx).Do(); err == nil { + t.Error("Get missing instance: expected error") + } + + if _, err := svc.Users.List(project, "ghost").Context(ctx).Do(); err == nil { + t.Error("Users.List on missing instance: expected error") + } + + if _, err := svc.Databases.List(project, "ghost").Context(ctx).Do(); err == nil { + t.Error("Databases.List on missing instance: expected error") + } + + if _, err := svc.BackupRuns.Delete(project, "ghost", 12345).Context(ctx).Do(); err == nil { + t.Error("BackupRuns.Delete on missing instance: expected error") + } +} diff --git a/server/gcp/cloudsql/types.go b/server/gcp/cloudsql/types.go index 88a7ea6a..5531f4b0 100644 --- a/server/gcp/cloudsql/types.go +++ b/server/gcp/cloudsql/types.go @@ -17,19 +17,21 @@ const ( // sqlInstance is the JSON shape Cloud SQL expects for DatabaseInstance. type sqlInstance struct { - Kind string `json:"kind,omitempty"` - Name string `json:"name,omitempty"` - Project string `json:"project,omitempty"` - Region string `json:"region,omitempty"` - DatabaseVersion string `json:"databaseVersion,omitempty"` - State string `json:"state,omitempty"` - BackendType string `json:"backendType,omitempty"` - ConnectionName string `json:"connectionName,omitempty"` - SelfLink string `json:"selfLink,omitempty"` - RootPassword string `json:"rootPassword,omitempty"` - IPAddresses []ipMapping `json:"ipAddresses,omitempty"` - Settings *sqlSettings `json:"settings,omitempty"` - CreateTime string `json:"createTime,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Project string `json:"project,omitempty"` + Region string `json:"region,omitempty"` + DatabaseVersion string `json:"databaseVersion,omitempty"` + State string `json:"state,omitempty"` + BackendType string `json:"backendType,omitempty"` + ConnectionName string `json:"connectionName,omitempty"` + SelfLink string `json:"selfLink,omitempty"` + RootPassword string `json:"rootPassword,omitempty"` + MasterInstanceName string `json:"masterInstanceName,omitempty"` + ReplicaNames []string `json:"replicaNames,omitempty"` + IPAddresses []ipMapping `json:"ipAddresses,omitempty"` + Settings *sqlSettings `json:"settings,omitempty"` + CreateTime string `json:"createTime,omitempty"` } type sqlSettings struct { @@ -110,7 +112,7 @@ func doneOperation(project, name, opType, _ string) operation { func doneOperationWithTarget(project, name, opType, resourceType, target string) operation { op := doneOperation(project, name, opType, "") op.TargetID = target - op.TargetLink = "/sql/v1beta4/projects/" + project + "/" + resourceType + "/" + target + op.TargetLink = pathPrefix + project + "/" + resourceType + "/" + target return op } @@ -118,15 +120,17 @@ func doneOperationWithTarget(project, name, opType, resourceType, target string) // toSQLInstance converts a portable Instance to the wire shape. func toSQLInstance(inst *rdsdriver.Instance, project string) sqlInstance { return sqlInstance{ - Kind: "sql#instance", - Name: inst.ID, - Project: project, - Region: inst.AvailabilityZone, - DatabaseVersion: inst.Engine, - State: sqlState(inst.State), - BackendType: "SECOND_GEN", - ConnectionName: inst.Endpoint, - SelfLink: "/sql/v1beta4/projects/" + project + "/instances/" + inst.ID, + Kind: "sql#instance", + Name: inst.ID, + Project: project, + Region: inst.AvailabilityZone, + DatabaseVersion: inst.Engine, + State: sqlState(inst.State), + BackendType: "SECOND_GEN", + ConnectionName: inst.Endpoint, + MasterInstanceName: inst.ReadReplicaSource, + ReplicaNames: inst.ReadReplicaTargets, + SelfLink: pathPrefix + project + "/instances/" + inst.ID, IPAddresses: []ipMapping{ {IPAddress: "10.0.0.1", Type: "PRIVATE"}, }, diff --git a/server/wire/azurearm/azurearm.go b/server/wire/azurearm/azurearm.go index 9f350832..f3272d24 100644 --- a/server/wire/azurearm/azurearm.go +++ b/server/wire/azurearm/azurearm.go @@ -36,13 +36,14 @@ const pairLen = 2 // ResourcePath is a parsed ARM URL path. Fields are empty when not present in // the path (e.g., a subscription-scoped list has no ResourceGroup). type ResourcePath struct { - Subscription string - ResourceGroup string - Provider string // e.g. "Microsoft.Compute" - ResourceType string // e.g. "virtualMachines" or "locations" - ResourceName string // empty for collection paths - SubResource string // e.g. "start", "powerOff", "operationStatuses" - SubResourceName string // e.g. operation GUID for .../operationStatuses/{id} + Subscription string + ResourceGroup string + Provider string // e.g. "Microsoft.Compute" + ResourceType string // e.g. "virtualMachines" or "locations" + ResourceName string // empty for collection paths + SubResource string // e.g. "start", "powerOff", "operationStatuses" + SubResourceName string // e.g. operation GUID for .../operationStatuses/{id} + SubResourceAction string // e.g. "failover" for .../failoverGroups/{name}/failover } // ParsePath extracts the ARM path components from urlPath. Returns ok=false @@ -100,9 +101,11 @@ func parseResourceGroup(parts []string, i int, rp *ResourcePath) int { return i + pairLen } -// parseTrailing records {name}, {subResource}, and {subResourceName} segments -// if present. The fourth segment lets us model paths like -// .../locations/{loc}/operationStatuses/{id}. +// parseTrailing records {name}, {subResource}, {subResourceName} and a final +// {subResourceAction} segment if present. The last two let us model paths like +// .../locations/{loc}/operationStatuses/{id} and +// .../failoverGroups/{name}/failover, where the action verb must be preserved +// to distinguish e.g. planned failover from forceFailoverAllowDataLoss. func parseTrailing(parts []string, i int, rp *ResourcePath) { if i < len(parts) { rp.ResourceName = parts[i] @@ -116,6 +119,11 @@ func parseTrailing(parts []string, i int, rp *ResourcePath) { if i < len(parts) { rp.SubResourceName = parts[i] + i++ + } + + if i < len(parts) { + rp.SubResourceAction = parts[i] } } diff --git a/server/wire/azurearm/azurearm_test.go b/server/wire/azurearm/azurearm_test.go index df2c8645..d003d504 100644 --- a/server/wire/azurearm/azurearm_test.go +++ b/server/wire/azurearm/azurearm_test.go @@ -59,6 +59,19 @@ func TestParsePathSubResource(t *testing.T) { } } +func TestParsePathSubResourceAction(t *testing.T) { + rp, ok := azurearm.ParsePath( + "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Sql/servers/srv1/failoverGroups/fg1/forceFailoverAllowDataLoss") + if !ok { + t.Fatal("expected ok=true") + } + + if rp.ResourceName != "srv1" || rp.SubResource != "failoverGroups" || + rp.SubResourceName != "fg1" || rp.SubResourceAction != "forceFailoverAllowDataLoss" { + t.Errorf("rp=%+v", rp) + } +} + func TestParsePathRejectsNonARM(t *testing.T) { cases := []string{ "/", diff --git a/services/cost/cost.go b/services/cost/cost.go index 14437140..516e98b5 100644 --- a/services/cost/cost.go +++ b/services/cost/cost.go @@ -58,6 +58,8 @@ func defaultRates() map[string]float64 { // Aurora cluster grouping billed via storage/members, proxied at 0. "relationaldb:CreateInstance": 0.017, // db.t3.micro-equivalent instance-hour "relationaldb:CreateDBInstanceReadReplica": 0.017, + "relationaldb:RestoreInstanceFromSnapshot": 0.017, + "relationaldb:CreateManagedInstance": 0.50, // Azure SQL Managed Instance (GP 4-vCore) instance-hour "relationaldb:CreateDBProxy": 0.015, // proxy vCPU-hour proxy "relationaldb:CreateCluster": 0.0, // Aurora billed per member instance + ACU "relationaldb:CreateSnapshot": 0.0, // manual snapshot storage diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index bc1b1e7b..c1065fe7 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -51,7 +51,15 @@ type InstanceConfig struct { OptionGroupName string ClusterID string // empty for standalone, set for Aurora cluster members AvailabilityZone string - Tags map[string]string + // ElasticPoolID is the Azure SQL elastic pool a database belongs to (the + // pool's ARM resource ID); empty for standalone databases and non-Azure + // engines. + ElasticPoolID string + // MasterInstanceName marks this instance as a read replica of the named + // primary (Cloud SQL creates replicas via a normal insert with this field); + // empty for a standalone primary. + MasterInstanceName string + Tags map[string]string } // Instance describes a managed database instance. @@ -76,6 +84,7 @@ type Instance struct { OptionGroupName string ClusterID string AvailabilityZone string + ElasticPoolID string CreatedAt time.Time Tags map[string]string // ReadReplicaSource is the identifier of the primary this instance @@ -98,6 +107,7 @@ type ModifyInstanceInput struct { DBParameterGroupName string OptionGroupName string DBClusterParameterGroupName string + ElasticPoolID string Tags map[string]string } @@ -251,6 +261,355 @@ type SubnetGroups interface { DeleteDBSubnetGroup(ctx context.Context, name string) error } +// DatabaseConfig describes a logical database to create inside a server. +type DatabaseConfig struct { + Server string + Name string + Charset string + Collation string +} + +// Database is a logical database hosted by a managed server (Azure MySQL / +// PostgreSQL Flexible Server, Cloud SQL, Azure SQL). +type Database struct { + Server string + Name string + Charset string + Collation string + ARN string +} + +// Databases is an OPTIONAL capability for managing the logical databases inside +// a server. It is discovered by type assertion; drivers that do not implement +// it answer InvalidAction. +type Databases interface { + CreateDatabase(ctx context.Context, cfg DatabaseConfig) (*Database, error) + GetDatabase(ctx context.Context, server, name string) (*Database, error) + ListDatabases(ctx context.Context, server string) ([]Database, error) + DeleteDatabase(ctx context.Context, server, name string) error +} + +// FirewallRuleConfig describes a server firewall rule to create or replace. +type FirewallRuleConfig struct { + Server string + Name string + StartIPAddress string + EndIPAddress string +} + +// FirewallRule is a server-level IP allow rule. +type FirewallRule struct { + Server string + Name string + StartIPAddress string + EndIPAddress string + ARN string +} + +// FirewallRules is an OPTIONAL capability for managing server firewall rules, +// discovered by type assertion. +type FirewallRules interface { + CreateFirewallRule(ctx context.Context, cfg FirewallRuleConfig) (*FirewallRule, error) + GetFirewallRule(ctx context.Context, server, name string) (*FirewallRule, error) + ListFirewallRules(ctx context.Context, server string) ([]FirewallRule, error) + DeleteFirewallRule(ctx context.Context, server, name string) error +} + +// ConfigurationConfig sets a single server parameter value. +type ConfigurationConfig struct { + Server string + Name string + Value string +} + +// Configuration is a server parameter (engine setting). DefaultValue, +// DataType and AllowedValues describe the parameter; Source records whether the +// current value is a user override or the system default. +type Configuration struct { + Server string + Name string + Value string + Source string + DataType string + DefaultValue string + AllowedValues string + ARN string +} + +// Configurations is an OPTIONAL capability for reading and setting server +// parameters, discovered by type assertion. Parameters have engine defaults, so +// there is no create/delete — only set (update), get and list. +type Configurations interface { + SetConfiguration(ctx context.Context, cfg ConfigurationConfig) (*Configuration, error) + GetConfiguration(ctx context.Context, server, name string) (*Configuration, error) + ListConfigurations(ctx context.Context, server string) ([]Configuration, error) +} + +// BatchConfigurations is an OPTIONAL capability for applying several server +// parameters atomically (MySQL Flexible Server's updateConfigurations), +// discovered by type assertion. All entries are validated before any is +// applied, so a bad entry never leaves earlier ones persisted. +type BatchConfigurations interface { + BatchSetConfigurations(ctx context.Context, server string, cfgs []ConfigurationConfig) ([]Configuration, error) +} + +// Failover is an OPTIONAL capability that triggers a server failover to its +// standby, discovered by type assertion. +type Failover interface { + FailoverInstance(ctx context.Context, id string) error +} + +// VNetRuleConfig describes a virtual-network rule to create (Azure SQL). +type VNetRuleConfig struct { + Server string + Name string + SubnetID string + IgnoreMissingEndpoint bool +} + +// VNetRule allows traffic from a virtual-network subnet to a server. +type VNetRule struct { + Server string + Name string + SubnetID string + IgnoreMissingEndpoint bool + State string + ARN string +} + +// VNetRules is an OPTIONAL Azure SQL capability, discovered by type assertion. +type VNetRules interface { + CreateVNetRule(ctx context.Context, cfg VNetRuleConfig) (*VNetRule, error) + GetVNetRule(ctx context.Context, server, name string) (*VNetRule, error) + ListVNetRules(ctx context.Context, server string) ([]VNetRule, error) + DeleteVNetRule(ctx context.Context, server, name string) error +} + +// ElasticPoolConfig describes an elastic pool to create (Azure SQL). +type ElasticPoolConfig struct { + Server string + Name string + Location string + SKUName string + SKUTier string + MaxSizeBytes int64 + MinCapacity float64 + MaxCapacity float64 +} + +// ElasticPool is a shared-resource pool that databases on a server draw from. +type ElasticPool struct { + Server string + Name string + Location string + SKUName string + SKUTier string + MaxSizeBytes int64 + MinCapacity float64 + MaxCapacity float64 + State string + ARN string +} + +// ElasticPools is an OPTIONAL Azure SQL capability, discovered by type assertion. +type ElasticPools interface { + CreateElasticPool(ctx context.Context, cfg ElasticPoolConfig) (*ElasticPool, error) + UpdateElasticPool(ctx context.Context, cfg ElasticPoolConfig) (*ElasticPool, error) + GetElasticPool(ctx context.Context, server, name string) (*ElasticPool, error) + ListElasticPools(ctx context.Context, server string) ([]ElasticPool, error) + DeleteElasticPool(ctx context.Context, server, name string) error +} + +// FailoverGroupConfig describes a failover group to create (Azure SQL). +type FailoverGroupConfig struct { + Server string + Name string + FailoverPolicy string + GracePeriodMinutes int32 + PartnerServers []string + Databases []string +} + +// FailoverGroup groups databases that fail over together to a partner server. +type FailoverGroup struct { + Server string + Name string + FailoverPolicy string + GracePeriodMinutes int32 + PartnerServers []string + Databases []string + ReplicationRole string + ARN string +} + +// FailoverGroups is an OPTIONAL Azure SQL capability, discovered by type +// assertion. Failover flips the local replication role between Primary and +// Secondary. +type FailoverGroups interface { + CreateFailoverGroup(ctx context.Context, cfg FailoverGroupConfig) (*FailoverGroup, error) + UpdateFailoverGroup(ctx context.Context, cfg FailoverGroupConfig) (*FailoverGroup, error) + GetFailoverGroup(ctx context.Context, server, name string) (*FailoverGroup, error) + ListFailoverGroups(ctx context.Context, server string) ([]FailoverGroup, error) + DeleteFailoverGroup(ctx context.Context, server, name string) error + FailoverFailoverGroup(ctx context.Context, server, name string) (*FailoverGroup, error) +} + +// AADAdminConfig sets the Azure AD administrator on a server (Azure SQL). +type AADAdminConfig struct { + Server string + Login string + SID string + TenantID string +} + +// AADAdmin is a server's Azure Active Directory administrator. A server has at +// most one; Name is always "ActiveDirectory". +type AADAdmin struct { + Server string + Name string + Login string + SID string + TenantID string + ARN string +} + +// AADAdmins is an OPTIONAL Azure SQL capability, discovered by type assertion. +type AADAdmins interface { + SetAADAdmin(ctx context.Context, cfg AADAdminConfig) (*AADAdmin, error) + GetAADAdmin(ctx context.Context, server, name string) (*AADAdmin, error) + ListAADAdmins(ctx context.Context, server string) ([]AADAdmin, error) + DeleteAADAdmin(ctx context.Context, server, name string) error +} + +// ManagedInstanceConfig describes an Azure SQL Managed Instance to create. +type ManagedInstanceConfig struct { + Name string + Location string + AdminLogin string + SKUName string + SKUTier string + LicenseType string + SubnetID string + VCores int + StorageGB int + Tags map[string]string +} + +// ManagedInstance is a SQL Managed Instance — a fully-managed instance that +// hosts managed databases, distinct from the single-database logical server. +type ManagedInstance struct { + Name string + Location string + AdminLogin string + SKUName string + SKUTier string + LicenseType string + SubnetID string + VCores int + StorageGB int + State string + FQDN string + ARN string + Tags map[string]string +} + +// ManagedDatabaseConfig describes a database on a managed instance. +type ManagedDatabaseConfig struct { + Instance string + Name string + Collation string +} + +// ManagedDatabase is a database hosted on a managed instance. +type ManagedDatabase struct { + Instance string + Name string + Collation string + Status string + ARN string +} + +// ManagedInstances is an OPTIONAL Azure SQL capability covering SQL Managed +// Instances and their managed databases, discovered by type assertion. +type ManagedInstances interface { + CreateManagedInstance(ctx context.Context, cfg ManagedInstanceConfig) (*ManagedInstance, error) + UpdateManagedInstance(ctx context.Context, cfg ManagedInstanceConfig) (*ManagedInstance, error) + GetManagedInstance(ctx context.Context, name string) (*ManagedInstance, error) + ListManagedInstances(ctx context.Context) ([]ManagedInstance, error) + DeleteManagedInstance(ctx context.Context, name string) error + StartManagedInstance(ctx context.Context, name string) error + StopManagedInstance(ctx context.Context, name string) error + FailoverManagedInstance(ctx context.Context, name string) error + + CreateManagedDatabase(ctx context.Context, cfg ManagedDatabaseConfig) (*ManagedDatabase, error) + GetManagedDatabase(ctx context.Context, instance, name string) (*ManagedDatabase, error) + ListManagedDatabases(ctx context.Context, instance string) ([]ManagedDatabase, error) + DeleteManagedDatabase(ctx context.Context, instance, name string) error +} + +// UserConfig describes a database user to create or update (Cloud SQL). +type UserConfig struct { + Instance string + Name string + Host string + Password string +} + +// User is a database user account on a server/instance. +type User struct { + Instance string + Name string + Host string +} + +// Users is an OPTIONAL capability for managing database user accounts, +// discovered by type assertion. +type Users interface { + CreateUser(ctx context.Context, cfg UserConfig) (*User, error) + GetUser(ctx context.Context, instance, name string) (*User, error) + ListUsers(ctx context.Context, instance string) ([]User, error) + UpdateUser(ctx context.Context, cfg UserConfig) (*User, error) + DeleteUser(ctx context.Context, instance, name string) error +} + +// SslCertConfig describes a client SSL certificate to create (Cloud SQL). +type SslCertConfig struct { + Instance string + CommonName string +} + +// SslCert is a client SSL certificate for connecting to an instance. The mock +// derives a deterministic fingerprint from the common name and returns a +// placeholder PEM so SDK round-trips carry a well-formed shape. +type SslCert struct { + Instance string + CommonName string + Sha1Fingerprint string + Cert string + SerialNumber string +} + +// SslCerts is an OPTIONAL capability for managing client SSL certificates, +// discovered by type assertion. +type SslCerts interface { + CreateSslCert(ctx context.Context, cfg SslCertConfig) (*SslCert, error) + GetSslCert(ctx context.Context, instance, sha1 string) (*SslCert, error) + ListSslCerts(ctx context.Context, instance string) ([]SslCert, error) + DeleteSslCert(ctx context.Context, instance, sha1 string) error +} + +// Clonable is an OPTIONAL capability that copies an instance to a new one +// (Cloud SQL), discovered by type assertion. +type Clonable interface { + CloneInstance(ctx context.Context, sourceID, destID string) (*Instance, error) +} + +// ReplicaPromotion is an OPTIONAL capability that promotes a read replica to a +// standalone primary (Cloud SQL), discovered by type assertion. +type ReplicaPromotion interface { + PromoteReplica(ctx context.Context, id string) error +} + // Parameter is a single engine parameter within a parameter group. Only // user-set parameters are modeled; the emulator does not fabricate the hundreds // of engine defaults real AWS returns. @@ -410,6 +769,15 @@ type AdvancedRestore interface { RestoreDBClusterToPointInTime(ctx context.Context, input RestoreClusterToPointInTimeInput) (*Cluster, error) } +// BackupRestorer is an OPTIONAL capability for restoring a backup in place +// onto an existing instance, discovered by type assertion. Cloud SQL's +// restoreBackup overwrites the target instance's data from a backup run rather +// than provisioning a new instance (unlike RestoreInstanceFromSnapshot), so the +// target must already exist. +type BackupRestorer interface { + RestoreBackup(ctx context.Context, targetInstanceID, backupRunID string) (*Instance, error) +} + // ProxyAuth is one authentication config entry on a DB proxy. type ProxyAuth struct { AuthScheme string // "SECRETS" diff --git a/services/resourcediscovery/engine.go b/services/resourcediscovery/engine.go index f76f9ca3..7e435c63 100644 --- a/services/resourcediscovery/engine.go +++ b/services/resourcediscovery/engine.go @@ -27,22 +27,26 @@ type Drivers struct { } // RelationalDatabases is the discovery capability for managed relational -// databases (RDS/Aurora and family). Like KubernetesClusters, the concrete -// mocks live in provider packages, so each provider wires a thin adapter that -// projects its databases onto DiscoveredDatabase rather than having services/ -// import providers. +// database servers/instances — RDS/Aurora, Azure SQL, Azure MySQL/PostgreSQL +// Flexible Server, Cloud SQL. Like KubernetesClusters, each cloud's relational +// mock lives in its provider package, so a thin adapter in the provider +// projects its databases onto DiscoveredDatabase rather than inverting the +// package layering. type RelationalDatabases interface { DiscoverDatabases(ctx context.Context) ([]DiscoveredDatabase, error) } // DiscoveredDatabase is a provider-neutral projection of a managed relational -// database resource (instance, cluster, or snapshot). ARN is used verbatim as -// the resource identifier; Region falls back to the engine default when empty. +// database resource for the inventory walk. Type is the portable resource type +// (e.g. "DBInstance", "SqlServer", "MySqlFlexibleServer", "SqlInstance") that +// Resource Explorer / Resource Graph / Cloud Asset translate to the cloud's +// native type string. ARN is used verbatim as the identifier; Region falls back +// to the engine default when empty. type DiscoveredDatabase struct { Name string ARN string Region string - Type string // one of Type{DBInstance,DBCluster,DBSnapshot} + Type string Tags map[string]string } diff --git a/services/resourcediscovery/relationaldb_walk_test.go b/services/resourcediscovery/relationaldb_walk_test.go new file mode 100644 index 00000000..bc1c5186 --- /dev/null +++ b/services/resourcediscovery/relationaldb_walk_test.go @@ -0,0 +1,72 @@ +package resourcediscovery + +import ( + "context" + "errors" + "testing" +) + +type fakeRelational struct { + dbs []DiscoveredDatabase + err error +} + +func (f fakeRelational) DiscoverDatabases(context.Context) ([]DiscoveredDatabase, error) { + if f.err != nil { + return nil, f.err + } + + return f.dbs, nil +} + +// Managed relational servers must surface in the inventory, carrying the +// per-cloud portable Type and their own region/ARN. +func TestWalkRelationalDBSurfacesServers(t *testing.T) { + eng := New(ProviderAzure, "sub-1", "eastus", &Drivers{ + RelationalDB: fakeRelational{dbs: []DiscoveredDatabase{ + {Name: "sql1", Type: TypeSQLServer, ARN: "/subscriptions/sub-1/.../servers/sql1"}, + {Name: "my1", Type: TypeMySQLFlex, Region: "westus", ARN: "arn-my1"}, + {Name: "pg1", Type: TypePostgresFlex}, // region falls back to engine default + }}, + }) + + got, err := eng.walkRelationalDB(context.Background()) + if err != nil { + t.Fatalf("walkRelationalDB: %v", err) + } + + if len(got) != 3 { + t.Fatalf("got %d resources, want 3", len(got)) + } + + byName := map[string]Resource{} + for i := range got { + if got[i].Service != ServiceRelationalDB { + t.Errorf("%s: service = %q, want %q", got[i].ID, got[i].Service, ServiceRelationalDB) + } + + byName[got[i].ID] = got[i] + } + + if byName["sql1"].Type != TypeSQLServer { + t.Errorf("sql1 type = %q, want %q", byName["sql1"].Type, TypeSQLServer) + } + + if byName["my1"].Region != "westus" { + t.Errorf("my1 region = %q, want westus", byName["my1"].Region) + } + + if byName["pg1"].Region != "eastus" { + t.Errorf("pg1 region = %q, want engine default eastus", byName["pg1"].Region) + } +} + +func TestWalkRelationalDBPropagatesErrors(t *testing.T) { + eng := New(ProviderGCP, "proj", "us-east1", &Drivers{ + RelationalDB: fakeRelational{err: errors.New("list databases failed")}, + }) + + if _, err := eng.walkRelationalDB(context.Background()); err == nil { + t.Error("a failing database listing should surface, not be swallowed") + } +} diff --git a/services/resourcediscovery/walkers.go b/services/resourcediscovery/walkers.go index 09c8ae70..3d165cd9 100644 --- a/services/resourcediscovery/walkers.go +++ b/services/resourcediscovery/walkers.go @@ -49,6 +49,17 @@ const ( TypeDBSnapshot = "DBSnapshot" ) +// Azure/GCP managed-SQL server types. These portable types map to per-cloud +// native type strings in Resource Graph (Azure) and Cloud Asset (GCP). AWS RDS +// uses TypeDBInstance/DBCluster/DBSnapshot above. +const ( + TypeSQLServer = "SqlServer" // Azure SQL logical server + TypeMySQLFlex = "MySqlFlexibleServer" // Azure Database for MySQL Flexible Server + TypePostgresFlex = "PostgresFlexibleServer" // Azure Database for PostgreSQL Flexible Server + TypeSQLInstance = "SqlInstance" // GCP Cloud SQL instance + TypeManagedInstance = "SqlManagedInstance" // Azure SQL Managed Instance +) + func (e *Engine) walkCompute(ctx context.Context) ([]Resource, error) { // Resource discovery is an internal/system walk: managed (service-owned) // instances still exist and must be discoverable even when the account @@ -347,6 +358,11 @@ func (e *Engine) walkKubernetes(ctx context.Context) ([]Resource, error) { return out, nil } +// walkRelationalDB surfaces managed relational database servers (RDS/Aurora, +// Azure SQL, Azure MySQL/PostgreSQL Flexible Server, Cloud SQL). The provider +// supplies a DiscoverDatabases adapter; each server becomes a relational-db +// resource whose Type carries the per-cloud kind so Resource Explorer / +// Resource Graph / Cloud Asset can translate it to the native type string. func (e *Engine) walkRelationalDB(ctx context.Context) ([]Resource, error) { dbs, err := e.drivers.RelationalDB.DiscoverDatabases(ctx) if err != nil {