From c357b94a9c5c48cb92392daf6d1b58a423ee3258 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 16:18:18 +0530 Subject: [PATCH 01/17] feat(azure): full parity for Azure MySQL Flexible Server sub-resources Add databases, firewall rules and server configurations plus the server failover action to Azure Database for MySQL Flexible Server, bringing it to native parity with the ARM surface real armmysqlflexibleservers clients use. Introduce Databases, FirewallRules, Configurations and Failover as optional relationaldb driver capabilities (mirroring SubnetGroups), so the same interfaces are reusable by the other managed-SQL services. The mock stores each family per server and cascade-deletes children on server delete; the ARM handler routes databases/firewallRules/configurations, updateConfigurations (batch) and the failover action. Covered by real-SDK round-trip tests and mock-level error-path/cascade tests. --- providers/azure/mysqlflex/mysqlflex.go | 41 +- providers/azure/mysqlflex/mysqlflex_test.go | 76 +++ providers/azure/mysqlflex/subresources.go | 303 ++++++++++++ server/azure/mysqlflex/handler.go | 32 +- server/azure/mysqlflex/sdk_roundtrip_test.go | 10 +- server/azure/mysqlflex/subresources.go | 457 ++++++++++++++++++ .../azure/mysqlflex/subresources_sdk_test.go | 219 +++++++++ services/relationaldb/driver/driver.go | 90 ++++ 8 files changed, 1218 insertions(+), 10 deletions(-) create mode 100644 providers/azure/mysqlflex/subresources.go create mode 100644 server/azure/mysqlflex/subresources.go create mode 100644 server/azure/mysqlflex/subresources_sdk_test.go diff --git a/providers/azure/mysqlflex/mysqlflex.go b/providers/azure/mysqlflex/mysqlflex.go index cbc4108a..3b95880f 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, } } @@ -264,9 +272,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, diff --git a/providers/azure/mysqlflex/mysqlflex_test.go b/providers/azure/mysqlflex/mysqlflex_test.go index 218cc2f5..054584e7 100644 --- a/providers/azure/mysqlflex/mysqlflex_test.go +++ b/providers/azure/mysqlflex/mysqlflex_test.go @@ -263,3 +263,79 @@ 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: "k"}); 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"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "k", Value: "v"}); 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 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") + } +} diff --git a/providers/azure/mysqlflex/subresources.go b/providers/azure/mysqlflex/subresources.go new file mode 100644 index 00000000..e7a84786 --- /dev/null +++ b/providers/azure/mysqlflex/subresources.go @@ -0,0 +1,303 @@ +package mysqlflex + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// 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.Failover = (*Mock)(nil) +) + +const defaultCollation = "utf8mb4_general_ci" + +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.All() { + 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") + } + + 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.All() { + 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") + } + + 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. +func (m *Mock) GetConfiguration(_ context.Context, server, name string) (*rdsdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + conf, ok := m.configurations.Get(childKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + out := conf + + return &out, nil +} + +// ListConfigurations returns the parameters that have been set on a server. +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 + } + + out := []rdsdriver.Configuration{} + + //nolint:gocritic // map values materialized into the result slice. + for _, conf := range m.configurations.All() { + if conf.Server == server { + out = append(out, conf) + } + } + + 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/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/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..e2ddf6e5 --- /dev/null +++ b/server/azure/mysqlflex/subresources.go @@ -0,0 +1,457 @@ +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 + } + + 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 + } + + if _, err := cf.SetConfiguration(r.Context(), cfg); 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..861fbcf4 --- /dev/null +++ b/server/azure/mysqlflex/subresources_sdk_test.go @@ -0,0 +1,219 @@ +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) + } + + if len(page.Value) != 2 { + t.Fatalf("got %d configurations, want 2", len(page.Value)) + } +} + +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/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index c56b8e10..bec442f4 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -234,3 +234,93 @@ type SubnetGroups interface { DescribeDBSubnetGroups(ctx context.Context, names []string) ([]SubnetGroup, error) 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) +} + +// 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 +} From c582db0474d0e46d5d833ac4737cf2a16d49667f Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 16:44:43 +0530 Subject: [PATCH 02/17] feat(azure): full parity for Azure PostgreSQL Flexible Server sub-resources Add databases, firewall rules and server configurations to Azure Database for PostgreSQL Flexible Server, reusing the Databases/FirewallRules/Configurations optional relationaldb capabilities introduced for MySQL Flex. Postgres Flex has no failover action and no batch-configuration endpoint, and its configuration resource accepts both PUT and PATCH; the handler and mock reflect that. The mock cascade-deletes children on server delete. Covered by real-SDK round-trip tests (the SDK has no ClientFactory, so each client is built from shared options) plus mock-level default/error/cascade tests. --- providers/azure/postgresflex/postgresflex.go | 41 +- .../azure/postgresflex/postgresflex_test.go | 55 +++ providers/azure/postgresflex/subresources.go | 286 +++++++++++++ server/azure/postgresflex/handler.go | 20 +- .../azure/postgresflex/sdk_roundtrip_test.go | 16 +- server/azure/postgresflex/subresources.go | 388 ++++++++++++++++++ .../postgresflex/subresources_sdk_test.go | 195 +++++++++ 7 files changed, 993 insertions(+), 8 deletions(-) create mode 100644 providers/azure/postgresflex/subresources.go create mode 100644 server/azure/postgresflex/subresources.go create mode 100644 server/azure/postgresflex/subresources_sdk_test.go diff --git a/providers/azure/postgresflex/postgresflex.go b/providers/azure/postgresflex/postgresflex.go index 18ca63cf..93276d45 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, } } @@ -275,9 +283,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") diff --git a/providers/azure/postgresflex/postgresflex_test.go b/providers/azure/postgresflex/postgresflex_test.go index f5970bfe..6aa594a3 100644 --- a/providers/azure/postgresflex/postgresflex_test.go +++ b/providers/azure/postgresflex/postgresflex_test.go @@ -271,3 +271,58 @@ 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: "k"}); 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"}); err != nil { + t.Fatalf("CreateFirewallRule: %v", err) + } + + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "k", Value: "v"}); 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") + } +} diff --git a/providers/azure/postgresflex/subresources.go b/providers/azure/postgresflex/subresources.go new file mode 100644 index 00000000..7748fb91 --- /dev/null +++ b/providers/azure/postgresflex/subresources.go @@ -0,0 +1,286 @@ +package postgresflex + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// 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" +) + +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.All() { + 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") + } + + 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.All() { + 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") + } + + 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. +func (m *Mock) GetConfiguration(_ context.Context, server, name string) (*rdsdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + conf, ok := m.configurations.Get(childKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + out := conf + + return &out, nil +} + +// ListConfigurations returns the parameters that have been set on a server. +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 + } + + out := []rdsdriver.Configuration{} + + //nolint:gocritic // map values materialized into the result slice. + for _, conf := range m.configurations.All() { + if conf.Server == server { + out = append(out, conf) + } + } + + return out, nil +} 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/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..384328c2 --- /dev/null +++ b/server/azure/postgresflex/subresources_sdk_test.go @@ -0,0 +1,195 @@ +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) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d configurations, want 1", len(page.Value)) + } +} From 89829171b51c6e69fd677d6ad9c8af3de600937b Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 17:01:16 +0530 Subject: [PATCH 03/17] feat(azure): full parity for Azure SQL server sub-resources Add firewall rules, virtual-network rules, elastic pools, failover groups and the Azure AD administrator to Azure SQL (Microsoft.Sql). Firewall rules reuse the shared FirewallRules capability; the other four are added as optional relationaldb capabilities (VNetRules, ElasticPools, FailoverGroups, AADAdmins) alongside the existing SubnetGroups pattern. Failover-group failover flips the local replication role between Primary and Secondary. The mock cascade-deletes all child resources on server delete and returns isolated copies of the slice-bearing failover-group state. Covered by real-SDK (armsql) round-trip tests across all five families plus mock-level error/cascade/aliasing tests. --- providers/azure/azuresql/azuresql.go | 56 +- providers/azure/azuresql/azuresql_test.go | 74 ++ providers/azure/azuresql/subresources.go | 483 +++++++++++++ server/azure/azuresql/handler.go | 28 +- server/azure/azuresql/sdk_roundtrip_test.go | 10 +- server/azure/azuresql/subresources.go | 673 ++++++++++++++++++ .../azure/azuresql/subresources_sdk_test.go | 287 ++++++++ services/relationaldb/driver/driver.go | 120 ++++ 8 files changed, 1723 insertions(+), 8 deletions(-) create mode 100644 providers/azure/azuresql/subresources.go create mode 100644 server/azure/azuresql/subresources.go create mode 100644 server/azure/azuresql/subresources_sdk_test.go diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index dc777799..506a53a5 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,14 @@ 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] + opts *config.Options monitoring mondriver.Monitoring } @@ -65,10 +74,15 @@ 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](), + opts: opts, } } @@ -484,10 +498,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. diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index ec412754..943c3b2a 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -245,3 +245,77 @@ 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 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"}); 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") + } +} diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go new file mode 100644 index 00000000..2186eb50 --- /dev/null +++ b/providers/azure/azuresql/subresources.go @@ -0,0 +1,483 @@ +package azuresql + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// 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 } + +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") + } + + 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.All() { + 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.All() { + 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) + + out := pool + + return &out, nil +} + +// 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{} + + //nolint:gocritic // map values materialized into the result slice. + for _, pool := range m.elasticPools.All() { + if pool.Server == server { + out = append(out, pool) + } + } + + return out, nil +} + +// DeleteElasticPool removes an elastic pool. +func (m *Mock) DeleteElasticPool(_ context.Context, server, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.elasticPools.Delete(subKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "elastic pool %q not found", name) + } + + return 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{} + + //nolint:gocritic // map values materialized into the result slice. + for _, fg := range m.failoverGroups.All() { + if fg.Server == server { + out = append(out, *copyFailoverGroup(fg)) + } + } + + 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) + } + + 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 +} + +// ---- 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/server/azure/azuresql/handler.go b/server/azure/azuresql/handler.go index 27f207b0..297b3f2a 100644 --- a/server/azure/azuresql/handler.go +++ b/server/azure/azuresql/handler.go @@ -31,6 +31,12 @@ const ( providerName = "Microsoft.Sql" resourceServers = "servers" subResourceDatabases = "databases" + + subFirewallRules = "firewallRules" + subVNetRules = "virtualNetworkRules" + subElasticPools = "elasticPools" + subFailoverGroups = "failoverGroups" + subAdministrators = "administrators" ) // Handler serves Microsoft.Sql ARM requests against a relationaldb driver. @@ -61,9 +67,25 @@ 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) + // Child resources: .../servers/{srv}/{type}[/{name}]. + if rp.SubResource != "" { + 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) + } + return } diff --git a/server/azure/azuresql/sdk_roundtrip_test.go b/server/azure/azuresql/sdk_roundtrip_test.go index 3fcc236a..f895fce0 100644 --- a/server/azure/azuresql/sdk_roundtrip_test.go +++ b/server/azure/azuresql/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 newSDKClients(t *testing.T) (*armsql.ServersClient, *armsql.DatabasesClient) { +func newFactory(t *testing.T) *armsql.ClientFactory { t.Helper() cloudP := cloudemu.NewAzure() @@ -55,6 +55,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() } diff --git a/server/azure/azuresql/subresources.go b/server/azure/azuresql/subresources.go new file mode 100644 index 00000000..3e32a628 --- /dev/null +++ b/server/azure/azuresql/subresources.go @@ -0,0 +1,673 @@ +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, http.MethodPatch: + h.putElasticPool(w, r, rp, ep) + 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) + } +} + +func (*Handler) putElasticPool( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, ep rdsdriver.ElasticPools, +) { + 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 + } + } + + out, err := ep.CreateElasticPool(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, http.MethodPatch: + h.putFailoverGroup(w, r, rp, fg) + 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) + } +} + +func (*Handler) putFailoverGroup( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fg rdsdriver.FailoverGroups, +) { + 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) + } + } + + out, err := fg.CreateFailoverGroup(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, +) { + 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"` +} + +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..54720baf --- /dev/null +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -0,0 +1,287 @@ +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) + } + + 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) + } +} diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index bec442f4..75621d5e 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -324,3 +324,123 @@ type Configurations interface { 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) + 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) + 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 +} From eed4e1427c51a53698bbff80f07b75584cee6ffb Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 17:13:01 +0530 Subject: [PATCH 04/17] feat(gcp): full parity for Cloud SQL databases, users, certs and instance ops Add databases (via the shared Databases capability), users and client SSL certs as instance child resources, plus the clone, failover, promote-replica and start/stop-replica instance actions to GCP Cloud SQL. Users and SSL certs are new optional relationaldb capabilities (Users, SslCerts); clone and replica promotion are Clonable and ReplicaPromotion; failover reuses the shared Failover capability. The REST path parser now recognizes the databases/users/sslCerts sub-collections, and the users route honors Cloud SQL's ?name= query quirk for delete and update. The mock cascade-deletes children on instance delete. Tiers and flags catalogs are intentionally out of scope (separate path shapes, static data). Covered by real sqladmin SDK round-trip tests plus mock-level clone/cascade/error tests. --- providers/gcp/cloudsql/cloudsql.go | 35 ++ providers/gcp/cloudsql/cloudsql_test.go | 81 ++++ providers/gcp/cloudsql/subresources.go | 390 ++++++++++++++++ server/gcp/cloudsql/handler.go | 42 +- server/gcp/cloudsql/subresources.go | 468 +++++++++++++++++++ server/gcp/cloudsql/subresources_sdk_test.go | 173 +++++++ services/relationaldb/driver/driver.go | 63 +++ 7 files changed, 1248 insertions(+), 4 deletions(-) create mode 100644 providers/gcp/cloudsql/subresources.go create mode 100644 server/gcp/cloudsql/subresources.go create mode 100644 server/gcp/cloudsql/subresources_sdk_test.go diff --git a/providers/gcp/cloudsql/cloudsql.go b/providers/gcp/cloudsql/cloudsql.go index 703efbdb..5ca8da6d 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" @@ -42,6 +43,11 @@ 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] + opts *config.Options monitoring mondriver.Monitoring } @@ -51,6 +57,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, } } @@ -267,9 +276,35 @@ func (m *Mock) DeleteInstance(_ context.Context, id string) error { return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) } + m.deleteChildren(id) + return nil } +// 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 { diff --git a/providers/gcp/cloudsql/cloudsql_test.go b/providers/gcp/cloudsql/cloudsql_test.go index f7554b4d..3fc2c781 100644 --- a/providers/gcp/cloudsql/cloudsql_test.go +++ b/providers/gcp/cloudsql/cloudsql_test.go @@ -230,3 +230,84 @@ 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) + } + + if err := m.FailoverInstance(ctx, "i"); err != nil { + t.Errorf("FailoverInstance: %v", err) + } + + if err := m.PromoteReplica(ctx, "i"); err != nil { + t.Errorf("PromoteReplica: %v", err) + } + + if err := m.FailoverInstance(ctx, "ghost"); err == nil { + t.Error("FailoverInstance on missing instance: expected NotFound") + } +} diff --git a/providers/gcp/cloudsql/subresources.go b/providers/gcp/cloudsql/subresources.go new file mode 100644 index 00000000..af519294 --- /dev/null +++ b/providers/gcp/cloudsql/subresources.go @@ -0,0 +1,390 @@ +package cloudsql + +import ( + "context" + "crypto/sha1" //nolint:gosec // SHA-1 fingerprints are the Cloud SQL cert identifier format, not a security control. + "encoding/hex" + + 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 } + +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") + } + + 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.All() { + 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") + } + + 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.All() { + if user.Instance == instance { + out = append(out, user) + } + } + + return out, nil +} + +// UpdateUser updates an existing user (host is the only mutable field the mock +// tracks). Cloud SQL's Update is idempotent create-or-update. +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.All() { + 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 validates the instance exists and re-emits metrics. Cloud +// SQL failover promotes the standby of a regional instance; the mock keeps the +// instance available. +func (m *Mock) FailoverInstance(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.instances.Get(id); !ok { + return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) + } + + m.emitInstanceMetrics(id, cpuMetricRunning, connRunning) + + return nil +} + +// PromoteReplica validates the instance exists. Cloud SQL detaches the replica +// from its primary and makes it a standalone instance; the mock keeps it +// available. +func (m *Mock) PromoteReplica(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.instances.Get(id); !ok { + return cerrors.Newf(cerrors.NotFound, "Cloud SQL instance %q not found", id) + } + + return nil +} + +// 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() + + clone.VPCSecurityGroups = append([]string(nil), src.VPCSecurityGroups...) + clone.Tags = copyTags(src.Tags) + + m.instances.Set(destID, clone) + + m.emitInstanceMetrics(destID, cpuMetricRunning, connRunning) + + out := clone + + return &out, nil +} diff --git a/server/gcp/cloudsql/handler.go b/server/gcp/cloudsql/handler.go index 16290f7c..2cabedef 100644 --- a/server/gcp/cloudsql/handler.go +++ b/server/gcp/cloudsql/handler.go @@ -44,8 +44,22 @@ const ( resourceInstances = "instances" resourceOperations = "operations" resourceBackupRuns = "backupRuns" + resourceDatabases = "databases" + resourceUsers = "users" + resourceSslCerts = "sslCerts" ) +// 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 @@ -123,7 +137,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 @@ -156,13 +170,23 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } 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 +237,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/subresources.go b/server/gcp/cloudsql/subresources.go new file mode 100644 index 00000000..6b40ddd8 --- /dev/null +++ b/server/gcp/cloudsql/subresources.go @@ -0,0 +1,468 @@ +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)) +} + +func (h *Handler) startReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) { + if err := h.db.StartInstance(r.Context(), p.name); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "start-replica", "START_REPLICA", "instances", p.name)) +} + +func (h *Handler) stopReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) { + if err := h.db.StopInstance(r.Context(), p.name); err != nil { + writeErr(w, err) + 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..876a46a7 --- /dev/null +++ b/server/gcp/cloudsql/subresources_sdk_test.go @@ -0,0 +1,173 @@ +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)) + } + + 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. + 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, stop/start replica, promote replica all succeed on a live instance. + if _, err := svc.Instances.Failover(project, "pg", &sqladmin.InstancesFailoverRequest{}).Context(ctx).Do(); err != nil { + t.Fatalf("Instances.Failover: %v", err) + } + + if _, err := svc.Instances.StopReplica(project, "pg-clone").Context(ctx).Do(); err != nil { + t.Fatalf("Instances.StopReplica: %v", err) + } + + if _, err := svc.Instances.StartReplica(project, "pg-clone").Context(ctx).Do(); err != nil { + t.Fatalf("Instances.StartReplica: %v", err) + } + + if _, err := svc.Instances.PromoteReplica(project, "pg-clone").Context(ctx).Do(); err != nil { + t.Fatalf("Instances.PromoteReplica: %v", err) + } +} diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index 75621d5e..a0468306 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -444,3 +444,66 @@ type AADAdmins interface { ListAADAdmins(ctx context.Context, server string) ([]AADAdmin, error) DeleteAADAdmin(ctx context.Context, server, 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 +} From ab82573c9267ab3e8451fb9247c36a213c1f9bb4 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 17:22:36 +0530 Subject: [PATCH 05/17] feat(discovery): surface managed SQL servers in Resource Graph and Cloud Asset Add a RelationalDatabases discovery capability to the resource-discovery engine and a walkRelationalDB walker, mirroring the Kubernetes adapter pattern. Azure wires an adapter that projects Azure SQL logical servers plus MySQL/PostgreSQL Flexible Servers, and GCP projects Cloud SQL instances, so managed relational databases appear in cross-service inventory. Resource Graph maps the portable types to microsoft.sql/servers, microsoft.dbformysql/flexibleservers and microsoft.dbforpostgresql/flexibleservers; Cloud Asset maps Cloud SQL to sqladmin.googleapis.com/Instance. Both type-map switches become lookup tables to stay under the cyclomatic-complexity gate. Covered by walker and type-map tests. --- providers/azure/azure.go | 72 ++++++++++++++-- providers/gcp/gcp.go | 37 ++++++-- server/azure/resourcegraph/handler.go | 23 ++--- server/azure/resourcegraph/kql.go | 6 ++ .../azure/resourcegraph/portable_type_test.go | 3 + server/gcp/cloudasset/filter.go | 85 +++++++++---------- server/gcp/cloudasset/filter_test.go | 2 + services/resourcediscovery/engine.go | 41 +++++++-- .../relationaldb_walk_test.go | 72 ++++++++++++++++ services/resourcediscovery/walkers.go | 42 ++++++++- 10 files changed, 309 insertions(+), 74 deletions(-) create mode 100644 services/resourcediscovery/relationaldb_walk_test.go diff --git a/providers/azure/azure.go b/providers/azure/azure.go index f05fe446..b9f267b7 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,72 @@ 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) + + 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/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/resourcegraph/handler.go b/server/azure/resourcegraph/handler.go index 98250902..487a16b3 100644 --- a/server/azure/resourcegraph/handler.go +++ b/server/azure/resourcegraph/handler.go @@ -254,16 +254,19 @@ 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", + "database/SqlServer": "microsoft.sql/servers", + "database/MySqlFlexibleServer": "microsoft.dbformysql/flexibleservers", + "database/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..42be86b2 100644 --- a/server/azure/resourcegraph/kql.go +++ b/server/azure/resourcegraph/kql.go @@ -47,6 +47,9 @@ const ( azureTypeDatabrick = "microsoft.databricks/workspaces" azureTypeAKS = "microsoft.containerservice/managedclusters" azureTypeAgentPool = "microsoft.containerservice/managedclusters/agentpools" + azureTypeSQL = "microsoft.sql/servers" + azureTypeMySQLFlex = "microsoft.dbformysql/flexibleservers" + azureTypePgFlex = "microsoft.dbforpostgresql/flexibleservers" ) // Portable service identifiers as emitted by the resourcediscovery walkers. @@ -305,6 +308,9 @@ var azureToPortableType = map[string]portableResourceType{ //nolint:gochecknoglo azureTypeDatabrick: {portableDatabricks, "Workspace"}, azureTypeAKS: {portableKubernetes, "Cluster"}, azureTypeAgentPool: {portableKubernetes, "NodeGroup"}, + azureTypeSQL: {portableDatabase, "SqlServer"}, + azureTypeMySQLFlex: {portableDatabase, "MySqlFlexibleServer"}, + azureTypePgFlex: {portableDatabase, "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..14307003 100644 --- a/server/azure/resourcegraph/portable_type_test.go +++ b/server/azure/resourcegraph/portable_type_test.go @@ -14,6 +14,9 @@ func TestPortableToAzureType(t *testing.T) { {"databricks", "Workspace", "microsoft.databricks/workspaces"}, {"kubernetes", "Cluster", "microsoft.containerservice/managedclusters"}, {"kubernetes", "NodeGroup", "microsoft.containerservice/managedclusters/agentpools"}, + {"database", "SqlServer", "microsoft.sql/servers"}, + {"database", "MySqlFlexibleServer", "microsoft.dbformysql/flexibleservers"}, + {"database", "PostgresFlexibleServer", "microsoft.dbforpostgresql/flexibleservers"}, } for _, c := range cases { diff --git a/server/gcp/cloudasset/filter.go b/server/gcp/cloudasset/filter.go index 34e198c4..2940c319 100644 --- a/server/gcp/cloudasset/filter.go +++ b/server/gcp/cloudasset/filter.go @@ -46,6 +46,7 @@ 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. @@ -228,57 +229,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: {portableDatabase, "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, + portableDatabase + "/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..7d7e75c4 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", "database", "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"}, + {"database", "SqlInstance", "sqladmin.googleapis.com/Instance"}, } for _, c := range cases { diff --git a/services/resourcediscovery/engine.go b/services/resourcediscovery/engine.go index 9cf8eb4f..05f3ea5a 100644 --- a/services/resourcediscovery/engine.go +++ b/services/resourcediscovery/engine.go @@ -16,13 +16,36 @@ import ( // engine usable in partial test wirings and during the staged rollout of // per-service walkers in later phases. type Drivers struct { - Compute computedriver.Compute - Networking netdriver.Networking - Storage storagedriver.Bucket - Database dbdriver.Database - Serverless serverlessdriver.Serverless - Databricks dbxdriver.Databricks - Kubernetes KubernetesClusters + Compute computedriver.Compute + Networking netdriver.Networking + Storage storagedriver.Bucket + Database dbdriver.Database + Serverless serverlessdriver.Serverless + Databricks dbxdriver.Databricks + Kubernetes KubernetesClusters + RelationalDB RelationalDatabases +} + +// RelationalDatabases is the discovery capability for managed relational +// database servers/instances — RDS, 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 +// servers 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 server for the inventory walk. Type is the portable resource type +// (e.g. "SqlServer", "MySqlFlexibleServer", "SqlInstance") that Resource Graph / +// Cloud Asset translate to the cloud's native type string. ARN, when set, is +// used verbatim as the identifier; empty means the engine builds one. +type DiscoveredDatabase struct { + Name string + Type string + Region string + ARN string + Tags map[string]string } // KubernetesClusters is the discovery capability for managed Kubernetes — @@ -161,5 +184,9 @@ func (e *Engine) walkers() []func(context.Context) ([]Resource, error) { ws = append(ws, e.walkKubernetes) } + if e.drivers.RelationalDB != nil { + ws = append(ws, e.walkRelationalDB) + } + return ws } diff --git a/services/resourcediscovery/relationaldb_walk_test.go b/services/resourcediscovery/relationaldb_walk_test.go new file mode 100644 index 00000000..543cc241 --- /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 != ServiceDatabase { + t.Errorf("%s: service = %q, want %q", got[i].ID, got[i].Service, ServiceDatabase) + } + + 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 4625d669..a1dccc78 100644 --- a/services/resourcediscovery/walkers.go +++ b/services/resourcediscovery/walkers.go @@ -3,9 +3,9 @@ package resourcediscovery import ( "context" "fmt" - netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" cerrors "github.com/stackshy/cloudemu/v2/errors" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" ) // Provider name constants used for routing per-provider ARN construction. @@ -44,6 +44,16 @@ const ( TypeNodeGroup = "NodeGroup" ) +// Relational database server types. These portable types map to per-cloud +// native type strings in Resource Graph (Azure) and Cloud Asset (GCP). +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 + TypeDBInstance = "DBInstance" // AWS RDS instance +) + func (e *Engine) walkCompute(ctx context.Context) ([]Resource, error) { instances, err := e.drivers.Compute.DescribeInstances(ctx, nil, nil) if err != nil { @@ -338,6 +348,36 @@ func (e *Engine) walkKubernetes(ctx context.Context) ([]Resource, error) { return out, nil } +// walkRelationalDB surfaces managed relational database servers (RDS, Azure +// SQL, Azure MySQL/PostgreSQL Flexible Server, Cloud SQL). The provider supplies +// a DiscoverDatabases adapter; each server becomes a database-service resource +// whose Type carries the per-cloud kind so 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 { + return nil, fmt.Errorf("walkRelationalDB: %w", err) + } + + out := make([]Resource, 0, len(dbs)) + + for i := range dbs { + region := dbs[i].Region + if region == "" { + region = e.region + } + + out = append(out, Resource{ + Provider: e.provider, Service: ServiceDatabase, Type: dbs[i].Type, + ID: dbs[i].Name, + ARN: dbs[i].ARN, + Region: region, Tags: copyTags(dbs[i].Tags), + }) + } + + return out, nil +} + func copyTags(src map[string]string) map[string]string { if len(src) == 0 { return nil From a6c46393de63b676f38a4097064d7b30d7afed95 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 17:25:18 +0530 Subject: [PATCH 06/17] feat(cost): add managed relational-database rates Add relationaldb:* entries to the cost rate catalog so provisioning a managed database server/instance (RDS, Azure SQL, Azure MySQL/PostgreSQL Flexible Server, Cloud SQL) is billed per instance-hour, clusters per cluster-hour, and restores reuse the instance-hour, while snapshots and lifecycle actions are free. The portable "relationaldb" service name means one catalog covers every cloud. Covered by a cost-tracker test. --- services/cost/cost.go | 14 ++++++++++++++ services/cost/cost_test.go | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/services/cost/cost.go b/services/cost/cost.go index 3e1c076e..e29c02ad 100644 --- a/services/cost/cost.go +++ b/services/cost/cost.go @@ -129,6 +129,20 @@ func defaultRates() map[string]float64 { "azuresearch:IndexDocuments": 0.0000004, "azuresearch:CreateOrUpdateIndex": 0.0, "azuresearch:CreateOrUpdateIndexer": 0.0, + + // Relational databases (AWS RDS, Azure SQL, Azure MySQL/PostgreSQL + // Flexible Server, GCP Cloud SQL). Servers/instances are billed per + // instance-hour (proxied at create); lifecycle actions and restores + // reuse the instance-hour, snapshots are billed as backup storage + // separately. The portable service name is "relationaldb", so one + // catalog covers every cloud's managed relational offering. + "relationaldb:CreateInstance": 0.017, // db.t3.micro-equivalent instance-hour + "relationaldb:CreateCluster": 0.29, // Aurora-style cluster-hour + "relationaldb:RestoreInstanceFromSnapshot": 0.017, + "relationaldb:CreateSnapshot": 0.0, // backup storage billed separately + "relationaldb:StartInstance": 0.0, + "relationaldb:StopInstance": 0.0, + "relationaldb:RebootInstance": 0.0, } } diff --git a/services/cost/cost_test.go b/services/cost/cost_test.go index 6c524c65..57270321 100644 --- a/services/cost/cost_test.go +++ b/services/cost/cost_test.go @@ -52,6 +52,25 @@ func TestTracker_Record_And_TotalCost(t *testing.T) { } } +// The relational-database catalog must charge for provisioning a managed +// server/instance (billed per instance-hour) so cost estimates for RDS, Azure +// SQL, the Flexible Servers and Cloud SQL are non-zero, while lifecycle actions +// stay free. +func TestTracker_RelationalDBRates(t *testing.T) { + tracker := New() + + tracker.Record("relationaldb", "CreateInstance", 1) + tracker.Record("relationaldb", "CreateCluster", 1) + tracker.Record("relationaldb", "StartInstance", 1) + tracker.Record("relationaldb", "StopInstance", 1) + + byOp := tracker.CostByOperation() + assert.Greater(t, byOp["relationaldb:CreateInstance"], float64(0)) + assert.Greater(t, byOp["relationaldb:CreateCluster"], float64(0)) + assert.Equal(t, float64(0), byOp["relationaldb:StartInstance"]) + assert.Equal(t, float64(0), byOp["relationaldb:StopInstance"]) +} + func TestTracker_SetRate(t *testing.T) { tracker := New() tracker.SetRate("custom", "Operation", 1.5) From 3584c7fc31ea6c8ffa902cad8c651c3b6362ae86 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 17:27:29 +0530 Subject: [PATCH 07/17] docs: document full managed-SQL parity (sub-resources, discovery, cost) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the native sub-resource capabilities added to Azure SQL, the Azure MySQL/PostgreSQL Flexible Servers and Cloud SQL — databases, users, firewall/ vnet rules, configurations, elastic pools, failover groups, AAD admins, SSL certs, clone/failover/replica actions — along with their discovery surfacing and cost rates. Refresh the relational-database operation totals and the discovery driver list. --- docs/features.md | 2 +- docs/services.md | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/features.md b/docs/features.md index 2ef78def..ca10764e 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, and Serverless 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 (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 f068d2b2..3d442797 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1122,7 +1122,41 @@ answer `InvalidAction`. matching the real service. Callers tearing down a VPC list subnet groups and match on it. -**Total: 21 operations (+3 optional)** +### Native sub-resources (optional capabilities) + +Each managed-SQL service exposes its cloud's own child resources and actions. +Like `SubnetGroups`, 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 same 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` | + +Cloud SQL also serves the `startReplica`/`stopReplica` instance actions (mapped +onto Start/Stop). 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`) and are billed per instance-hour via the +`relationaldb:*` cost catalog. Cloud SQL `tiers`/`flags` catalogs are out of +scope (static reference data on separate path shapes). + +**Total: 21 operations (+3 SubnetGroup + 40 native sub-resource, across the 12 optional capabilities above)** --- @@ -1647,7 +1681,7 @@ still sees success. | Notification | 8 | | Container Registry | 14 | | Event Bus | 15 | -| Relational Database | 21 (+3 optional) | +| Relational Database | 21 (+43 optional) | | Kubernetes — AWS EKS (control plane) | 21 | | Kubernetes — Azure AKS (control plane) | 18 | | Kubernetes — GCP GKE (control plane) | 26 | @@ -1660,7 +1694,7 @@ still sees success. | Machine Learning — Azure AI (CognitiveServices + MachineLearningServices + data plane) | 92 | | Machine Learning — GCP Vertex AI (Go API/driver) | 128 | | AI Search — Azure AI Search (control + data plane) | 53 | -| **Grand Total** | **1047** (+12 optional) | +| **Grand Total** | **1047** (+52 optional) | Optional operations are capabilities a driver may implement but is not required to; see the sections marked "optional capability". They are counted separately From 36d8af08d454616ca57ef9c93a41d6c865e4fc03 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 17:52:22 +0530 Subject: [PATCH 08/17] feat(sql): Cloud SQL tiers/flags catalogs + elastic-pool metrics Close the remaining managed-SQL parity gaps: serve the Cloud SQL machine-tier catalog (GET /v1/projects/{p}/tiers) and the database-flag catalog (GET /v1/flags, which is project-less) as static reference data, and emit Microsoft.Sql/servers/elasticpools metrics (cpu/storage/workers percent) when an Azure SQL elastic pool is created. Also annotate the ModifyInstance/ModifyCluster methods across the four SQL mocks with the driver-interface hugeParam nolint now that the shared ModifyInstanceInput grew. Covered by SDK tiers/flags round-trip and an elastic-pool metric-emission test. --- providers/azure/azuresql/azuresql.go | 4 + providers/azure/azuresql/azuresql_test.go | 36 ++++++++ providers/azure/azuresql/subresources.go | 23 ++++++ providers/azure/mysqlflex/mysqlflex.go | 4 + providers/azure/postgresflex/postgresflex.go | 4 + providers/gcp/cloudsql/cloudsql.go | 4 + server/gcp/cloudsql/catalog.go | 87 ++++++++++++++++++++ server/gcp/cloudsql/handler.go | 24 ++++-- server/gcp/cloudsql/subresources_sdk_test.go | 38 +++++++++ 9 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 server/gcp/cloudsql/catalog.go diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index 506a53a5..f2369d17 100644 --- a/providers/azure/azuresql/azuresql.go +++ b/providers/azure/azuresql/azuresql.go @@ -274,6 +274,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) { @@ -456,6 +458,8 @@ func (m *Mock) DescribeClusters(_ context.Context, ids []string) ([]rdsdriver.Cl } // 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) { diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index 943c3b2a..372f0cb8 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/azure/azuremonitor" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -319,3 +320,38 @@ func TestFailoverGroupRoleFlipAndCascade(t *testing.T) { 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) + } +} diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go index 2186eb50..50e074dc 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -5,6 +5,7 @@ import ( 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" ) @@ -242,11 +243,33 @@ func (m *Mock) CreateElasticPool(_ context.Context, cfg rdsdriver.ElasticPoolCon 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() diff --git a/providers/azure/mysqlflex/mysqlflex.go b/providers/azure/mysqlflex/mysqlflex.go index 3b95880f..72f464be 100644 --- a/providers/azure/mysqlflex/mysqlflex.go +++ b/providers/azure/mysqlflex/mysqlflex.go @@ -225,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) { @@ -376,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/postgresflex/postgresflex.go b/providers/azure/postgresflex/postgresflex.go index 93276d45..17946a6a 100644 --- a/providers/azure/postgresflex/postgresflex.go +++ b/providers/azure/postgresflex/postgresflex.go @@ -236,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) { @@ -385,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/gcp/cloudsql/cloudsql.go b/providers/gcp/cloudsql/cloudsql.go index 5ca8da6d..45786e1c 100644 --- a/providers/gcp/cloudsql/cloudsql.go +++ b/providers/gcp/cloudsql/cloudsql.go @@ -229,6 +229,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) { @@ -380,6 +382,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) { 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 2cabedef..d48788f8 100644 --- a/server/gcp/cloudsql/handler.go +++ b/server/gcp/cloudsql/handler.go @@ -38,6 +38,7 @@ import ( const ( pathPrefix = "/v1/projects/" + pathFlags = "/v1/flags" contentTypeJSON = "application/json" maxBodyBytes = 1 << 20 @@ -47,6 +48,7 @@ const ( resourceDatabases = "databases" resourceUsers = "users" resourceSslCerts = "sslCerts" + resourceTiers = "tiers" ) // isSubResource reports whether seg is an instance-scoped sub-collection; any @@ -70,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 } @@ -88,7 +94,7 @@ func (*Handler) Matches(r *http.Request) bool { } switch parts[idxResource] { - case resourceInstances, resourceOperations: + case resourceInstances, resourceOperations, resourceTiers: return true } @@ -153,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") @@ -164,6 +176,8 @@ 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) } diff --git a/server/gcp/cloudsql/subresources_sdk_test.go b/server/gcp/cloudsql/subresources_sdk_test.go index 876a46a7..656d730f 100644 --- a/server/gcp/cloudsql/subresources_sdk_test.go +++ b/server/gcp/cloudsql/subresources_sdk_test.go @@ -171,3 +171,41 @@ func TestSDKCloudSQLInstanceActions(t *testing.T) { t.Fatalf("Instances.PromoteReplica: %v", err) } } + +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") + } +} From 1b06c82725449fc9e4c4c7dc53e54ecf65139a1c Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 18:00:27 +0530 Subject: [PATCH 09/17] feat(azure): add SQL Managed Instance family to Azure SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Microsoft.Sql/managedInstances resource type and its managed databases as a ManagedInstances optional relationaldb capability: managed-instance CRUD + list + start/stop/failover actions, and managed-database CRUD + list. The handler now matches the managedInstances resource type alongside servers and routes both, and the mock cascade-deletes managed databases when their instance is removed. Covered by an armsql managed-instance/database SDK round-trip test (create → get → list → failover → cascade delete) and a mock-level lifecycle test. --- providers/azure/azuresql/azuresql.go | 24 +- providers/azure/azuresql/azuresql_test.go | 39 +++ providers/azure/azuresql/managedinstance.go | 249 +++++++++++++++ server/azure/azuresql/handler.go | 63 ++-- server/azure/azuresql/managedinstance.go | 297 ++++++++++++++++++ server/azure/azuresql/subresources.go | 1 + .../azure/azuresql/subresources_sdk_test.go | 100 ++++++ services/relationaldb/driver/driver.go | 65 ++++ 8 files changed, 807 insertions(+), 31 deletions(-) create mode 100644 providers/azure/azuresql/managedinstance.go create mode 100644 server/azure/azuresql/managedinstance.go diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index f2369d17..788d7f6b 100644 --- a/providers/azure/azuresql/azuresql.go +++ b/providers/azure/azuresql/azuresql.go @@ -67,6 +67,10 @@ type Mock struct { // 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 } @@ -74,15 +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](), - 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](), - 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, } } diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index 372f0cb8..21cfe6f6 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -355,3 +355,42 @@ func TestElasticPoolEmitsMetrics(t *testing.T) { 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"}); 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") + } +} diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go new file mode 100644 index 00000000..622e7e44 --- /dev/null +++ b/providers/azure/azuresql/managedinstance.go @@ -0,0 +1,249 @@ +package azuresql + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + 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 +) + +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") + } + + 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: "Ready", + FQDN: cfg.Name + ".managed.database.windows.net", + ARN: m.miARN(cfg.Name), + Tags: copyTags(cfg.Tags), + } + + m.managedInstances.Set(cfg.Name, mi) + + out := mi + + 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 + + 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{} + + //nolint:gocritic // map values materialized into the result slice. + for _, mi := range m.managedInstances.All() { + 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 marks a managed instance ready. +func (m *Mock) StartManagedInstance(ctx context.Context, name string) error { + return m.setManagedInstanceState(ctx, name, "Ready") +} + +// StopManagedInstance marks a managed instance stopped. +func (m *Mock) StopManagedInstance(ctx context.Context, name string) error { + return m.setManagedInstanceState(ctx, name, "Stopped") +} + +// FailoverManagedInstance triggers a managed-instance failover; the instance +// stays ready. +func (m *Mock) FailoverManagedInstance(ctx context.Context, name string) error { + return m.setManagedInstanceState(ctx, name, "Ready") +} + +func (m *Mock) setManagedInstanceState(_ context.Context, name, state 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) + } + + mi.State = state + 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.All() { + 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/server/azure/azuresql/handler.go b/server/azure/azuresql/handler.go index 297b3f2a..dc7c8856 100644 --- a/server/azure/azuresql/handler.go +++ b/server/azure/azuresql/handler.go @@ -28,15 +28,20 @@ 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" ) // Handler serves Microsoft.Sql ARM requests against a relationaldb driver. @@ -49,14 +54,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. @@ -67,25 +76,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if rp.ResourceType == resourceManagedInstances { + h.serveManagedInstanceRoute(w, r, &rp) + return + } + // Child resources: .../servers/{srv}/{type}[/{name}]. if rp.SubResource != "" { - 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) - } - + h.serveServerChild(w, r, &rp) return } @@ -98,6 +96,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..15aa9eb4 --- /dev/null +++ b/server/azure/azuresql/managedinstance.go @@ -0,0 +1,297 @@ +package azuresql + +import ( + "net/http" + + "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.MethodGet, http.MethodPatch: + 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 (*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 := 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 + } + + out, err := mi.CreateManagedInstance(r.Context(), cfg) + if err != nil { + existing, getErr := mi.GetManagedInstance(r.Context(), rp.ResourceName) + if getErr != nil { + azurearm.WriteCErr(w, err) + return + } + + out = existing + } + + 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/subresources.go b/server/azure/azuresql/subresources.go index 3e32a628..e206d55a 100644 --- a/server/azure/azuresql/subresources.go +++ b/server/azure/azuresql/subresources.go @@ -575,6 +575,7 @@ type armAADAdminCfg struct { 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 { diff --git a/server/azure/azuresql/subresources_sdk_test.go b/server/azure/azuresql/subresources_sdk_test.go index 54720baf..f997f317 100644 --- a/server/azure/azuresql/subresources_sdk_test.go +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -285,3 +285,103 @@ func TestSDKAzureSQLAADAdmin(t *testing.T) { 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)) + } + + // 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") + } +} diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index 00b22ac1..23eb6242 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -461,6 +461,71 @@ type AADAdmins interface { 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) + 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 From 06402c70ab43ab60a5c9f5b2ba67c35b7715ada0 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 18:01:45 +0530 Subject: [PATCH 10/17] docs: document managed instances, tiers/flags and elastic-pool metrics Update the relational-database section for the now-complete managed-SQL parity: add the ManagedInstances capability row, note Cloud SQL's tiers/flags reference catalogs and Azure SQL's Managed Instance family, and record the elastic-pool metric namespace. Refresh the optional-operation totals (25 capability interfaces, +109 relational / +118 grand-total optional). --- docs/services.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/services.md b/docs/services.md index 38665f23..f946b284 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1231,24 +1231,29 @@ cascade-delete children when their parent server/instance is deleted. | `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). Managed relational servers surface in cross-service discovery -(Azure Resource Graph as `microsoft.sql/servers`, +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`) and are billed per instance-hour via the -`relationaldb:*` cost catalog. +`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 + 98 optional across 24 type-asserted capability +**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 12 Azure/GCP managed-SQL ones (`Databases`, +`Metadata`, `Tagging`) plus the 13 Azure/GCP managed-SQL ones (`Databases`, `FirewallRules`, `Configurations`, `Failover`, `VNetRules`, `ElasticPools`, `FailoverGroups`, `AADAdmins`, `Users`, `SslCerts`, `Clonable`, -`ReplicaPromotion`). Each cloud implements the subset that maps to a real -resource and answers `InvalidAction` otherwise. +`ReplicaPromotion`, `ManagedInstances`). Each cloud implements the subset that +maps to a real resource and answers `InvalidAction` otherwise. --- @@ -1775,7 +1780,7 @@ still sees success. | Notification | 8 | | Container Registry | 14 | | Event Bus | 15 | -| Relational Database | 21 (+98 optional) | +| Relational Database | 21 (+109 optional) | | Kubernetes — AWS EKS (control plane) | 21 | | Kubernetes — Azure AKS (control plane) | 18 | | Kubernetes — GCP GKE (control plane) | 26 | @@ -1788,7 +1793,7 @@ still sees success. | Machine Learning — Azure AI (CognitiveServices + MachineLearningServices + data plane) | 92 | | Machine Learning — GCP Vertex AI (Go API/driver) | 128 | | AI Search — Azure AI Search (control + data plane) | 53 | -| **Grand Total** | **1047** (+107 optional) | +| **Grand Total** | **1047** (+118 optional) | Optional operations are capabilities a driver may implement but is not required to; see the sections marked "optional capability". They are counted separately From 0922fa20ada5dc13ba8b1e3ad8b2bdc2c83e5fee Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 18:34:38 +0530 Subject: [PATCH 11/17] fix(sql): clone ManagedInstance tags on read; deterministic list ordering Address review MEDIUM #1 and the list-ordering LOW: GetManagedInstance / ListManagedInstances now clone the stored Tags map so a caller mutating the returned map can't corrupt the store (the copy-on-read hole flagged as the concurrent-map panic class), and every List* mock method iterates memstore.SortedValues() instead of All() for deterministic SDK list ordering, matching the documented convention. Large-value list loops use index iteration to avoid per-element copies. --- providers/azure/azuresql/managedinstance.go | 11 ++++++++--- providers/azure/azuresql/subresources.go | 20 ++++++++++---------- providers/azure/mysqlflex/subresources.go | 12 ++++++------ providers/azure/postgresflex/subresources.go | 12 ++++++------ providers/gcp/cloudsql/subresources.go | 6 +++--- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go index 622e7e44..1313425c 100644 --- a/providers/azure/azuresql/managedinstance.go +++ b/providers/azure/azuresql/managedinstance.go @@ -80,6 +80,7 @@ func (m *Mock) GetManagedInstance(_ context.Context, name string) (*rdsdriver.Ma } out := mi + out.Tags = copyTags(mi.Tags) return &out, nil } @@ -91,8 +92,12 @@ func (m *Mock) ListManagedInstances(_ context.Context) ([]rdsdriver.ManagedInsta out := []rdsdriver.ManagedInstance{} - //nolint:gocritic // map values materialized into the result slice. - for _, mi := range m.managedInstances.All() { + // 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) } @@ -211,7 +216,7 @@ func (m *Mock) ListManagedDatabases(_ context.Context, instance string) ([]rdsdr out := []rdsdriver.ManagedDatabase{} - for _, mdb := range m.managedDatabases.All() { + for _, mdb := range m.managedDatabases.SortedValues() { if mdb.Instance == instance { out = append(out, mdb) } diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go index 50e074dc..4229a310 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -107,7 +107,7 @@ func (m *Mock) ListFirewallRules(_ context.Context, server string) ([]rdsdriver. out := []rdsdriver.FirewallRule{} - for _, rule := range m.firewallRules.All() { + for _, rule := range m.firewallRules.SortedValues() { if rule.Server == server { out = append(out, rule) } @@ -185,7 +185,7 @@ func (m *Mock) ListVNetRules(_ context.Context, server string) ([]rdsdriver.VNet out := []rdsdriver.VNetRule{} - for _, rule := range m.vnetRules.All() { + for _, rule := range m.vnetRules.SortedValues() { if rule.Server == server { out = append(out, rule) } @@ -296,10 +296,10 @@ func (m *Mock) ListElasticPools(_ context.Context, server string) ([]rdsdriver.E out := []rdsdriver.ElasticPool{} - //nolint:gocritic // map values materialized into the result slice. - for _, pool := range m.elasticPools.All() { - if pool.Server == server { - out = append(out, pool) + pools := m.elasticPools.SortedValues() + for i := range pools { + if pools[i].Server == server { + out = append(out, pools[i]) } } @@ -378,10 +378,10 @@ func (m *Mock) ListFailoverGroups(_ context.Context, server string) ([]rdsdriver out := []rdsdriver.FailoverGroup{} - //nolint:gocritic // map values materialized into the result slice. - for _, fg := range m.failoverGroups.All() { - if fg.Server == server { - out = append(out, *copyFailoverGroup(fg)) + fgs := m.failoverGroups.SortedValues() + for i := range fgs { + if fgs[i].Server == server { + out = append(out, *copyFailoverGroup(fgs[i])) } } diff --git a/providers/azure/mysqlflex/subresources.go b/providers/azure/mysqlflex/subresources.go index e7a84786..23b04cd3 100644 --- a/providers/azure/mysqlflex/subresources.go +++ b/providers/azure/mysqlflex/subresources.go @@ -103,7 +103,7 @@ func (m *Mock) ListDatabases(_ context.Context, server string) ([]rdsdriver.Data out := []rdsdriver.Database{} - for _, db := range m.databases.All() { + for _, db := range m.databases.SortedValues() { if db.Server == server { out = append(out, db) } @@ -182,7 +182,7 @@ func (m *Mock) ListFirewallRules(_ context.Context, server string) ([]rdsdriver. out := []rdsdriver.FirewallRule{} - for _, rule := range m.firewallRules.All() { + for _, rule := range m.firewallRules.SortedValues() { if rule.Server == server { out = append(out, rule) } @@ -269,10 +269,10 @@ func (m *Mock) ListConfigurations(_ context.Context, server string) ([]rdsdriver out := []rdsdriver.Configuration{} - //nolint:gocritic // map values materialized into the result slice. - for _, conf := range m.configurations.All() { - if conf.Server == server { - out = append(out, conf) + confs := m.configurations.SortedValues() + for i := range confs { + if confs[i].Server == server { + out = append(out, confs[i]) } } diff --git a/providers/azure/postgresflex/subresources.go b/providers/azure/postgresflex/subresources.go index 7748fb91..670deeb9 100644 --- a/providers/azure/postgresflex/subresources.go +++ b/providers/azure/postgresflex/subresources.go @@ -109,7 +109,7 @@ func (m *Mock) ListDatabases(_ context.Context, server string) ([]rdsdriver.Data out := []rdsdriver.Database{} - for _, db := range m.databases.All() { + for _, db := range m.databases.SortedValues() { if db.Server == server { out = append(out, db) } @@ -188,7 +188,7 @@ func (m *Mock) ListFirewallRules(_ context.Context, server string) ([]rdsdriver. out := []rdsdriver.FirewallRule{} - for _, rule := range m.firewallRules.All() { + for _, rule := range m.firewallRules.SortedValues() { if rule.Server == server { out = append(out, rule) } @@ -275,10 +275,10 @@ func (m *Mock) ListConfigurations(_ context.Context, server string) ([]rdsdriver out := []rdsdriver.Configuration{} - //nolint:gocritic // map values materialized into the result slice. - for _, conf := range m.configurations.All() { - if conf.Server == server { - out = append(out, conf) + confs := m.configurations.SortedValues() + for i := range confs { + if confs[i].Server == server { + out = append(out, confs[i]) } } diff --git a/providers/gcp/cloudsql/subresources.go b/providers/gcp/cloudsql/subresources.go index af519294..baf0ed8e 100644 --- a/providers/gcp/cloudsql/subresources.go +++ b/providers/gcp/cloudsql/subresources.go @@ -110,7 +110,7 @@ func (m *Mock) ListDatabases(_ context.Context, instance string) ([]rdsdriver.Da out := []rdsdriver.Database{} - for _, db := range m.databases.All() { + for _, db := range m.databases.SortedValues() { if db.Server == instance { out = append(out, db) } @@ -190,7 +190,7 @@ func (m *Mock) ListUsers(_ context.Context, instance string) ([]rdsdriver.User, out := []rdsdriver.User{} - for _, user := range m.users.All() { + for _, user := range m.users.SortedValues() { if user.Instance == instance { out = append(out, user) } @@ -299,7 +299,7 @@ func (m *Mock) ListSslCerts(_ context.Context, instance string) ([]rdsdriver.Ssl out := []rdsdriver.SslCert{} - for _, cert := range m.sslCerts.All() { + for _, cert := range m.sslCerts.SortedValues() { if cert.Instance == instance { out = append(out, cert) } From a58f2d42dab2ab88eaad9a336d0e3319453fc3e8 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 18:42:52 +0530 Subject: [PATCH 12/17] fix(azure): real update semantics + discoverable managed instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review MEDIUM #2/#3/#4 (Azure update paths) and #7 (MI discovery): - PUT on an existing Azure SQL server / database / managed instance now applies the request body (upsert) instead of returning the stale record. - PATCH is a genuine merge — elastic pools, failover groups and managed instances gain Update* capability methods that overlay only the fields the request supplied, so a partial PATCH no longer wipes unspecified fields; MI PATCH now decodes and applies its body instead of being a no-op. - Managed instances are projected into cross-service discovery (microsoft.sql/managedinstances) via sqlDiscovery + the Resource Graph type map, so a created MI appears in inventory. Covered by SDK PATCH-merge round-trip tests (elastic pool keeps its SKU when only maxSizeBytes is patched; MI keeps administratorLogin when only vCores is patched) and a managed-instance type-map test. --- providers/azure/azure.go | 12 +++ providers/azure/azuresql/managedinstance.go | 37 ++++++++ providers/azure/azuresql/subresources.go | 89 ++++++++++++++++++ server/azure/azuresql/managedinstance.go | 54 ++++++++--- server/azure/azuresql/operations.go | 31 +++++-- server/azure/azuresql/subresources.go | 37 ++++++-- .../azure/azuresql/subresources_sdk_test.go | 92 +++++++++++++++++++ server/azure/resourcegraph/handler.go | 1 + server/azure/resourcegraph/kql.go | 2 + .../azure/resourcegraph/portable_type_test.go | 1 + services/relationaldb/driver/driver.go | 3 + services/resourcediscovery/walkers.go | 9 +- 12 files changed, 334 insertions(+), 34 deletions(-) diff --git a/providers/azure/azure.go b/providers/azure/azure.go index b9f267b7..f1af9924 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -208,6 +208,18 @@ func (d sqlDiscovery) DiscoverDatabases( 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 } diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go index 1313425c..cc94deda 100644 --- a/providers/azure/azuresql/managedinstance.go +++ b/providers/azure/azuresql/managedinstance.go @@ -69,6 +69,43 @@ func (m *Mock) CreateManagedInstance( return &out, nil } +// 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() diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go index 4229a310..c7e4961b 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -318,6 +318,52 @@ func (m *Mock) DeleteElasticPool(_ context.Context, server, name string) error { 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 @@ -434,6 +480,49 @@ func copyFailoverGroup(fg rdsdriver.FailoverGroup) *rdsdriver.FailoverGroup { 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). diff --git a/server/azure/azuresql/managedinstance.go b/server/azure/azuresql/managedinstance.go index 15aa9eb4..e8c95cce 100644 --- a/server/azure/azuresql/managedinstance.go +++ b/server/azure/azuresql/managedinstance.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" ) @@ -78,7 +79,9 @@ func (h *Handler) serveManagedInstance( switch r.Method { case http.MethodPut: h.putManagedInstance(w, r, rp, mi) - case http.MethodGet, http.MethodPatch: + 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 { @@ -92,14 +95,7 @@ func (h *Handler) serveManagedInstance( } } -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 - } - +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 @@ -114,15 +110,49 @@ func (*Handler) putManagedInstance( 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 { - existing, getErr := mi.GetManagedInstance(r.Context(), rp.ResourceName) - if getErr != nil { + if !cerrors.IsAlreadyExists(err) { azurearm.WriteCErr(w, err) return } - out = existing + // 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)) diff --git a/server/azure/azuresql/operations.go b/server/azure/azuresql/operations.go index 2d314757..bf023051 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)) @@ -147,14 +155,21 @@ func (h *Handler) createOrUpdateDatabase(w http.ResponseWriter, r *http.Request, 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, + Tags: body.Tags, + }) + if err != nil { + azurearm.WriteCErr(w, err) + return + } } azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(inst, rp.Subscription, rp.ResourceGroup)) diff --git a/server/azure/azuresql/subresources.go b/server/azure/azuresql/subresources.go index e206d55a..dd094e86 100644 --- a/server/azure/azuresql/subresources.go +++ b/server/azure/azuresql/subresources.go @@ -304,8 +304,10 @@ func (h *Handler) serveElasticPool(w http.ResponseWriter, r *http.Request, rp *a } switch r.Method { - case http.MethodPut, http.MethodPatch: - h.putElasticPool(w, r, rp, ep) + 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: @@ -320,8 +322,10 @@ func (h *Handler) serveElasticPool(w http.ResponseWriter, r *http.Request, rp *a } } -func (*Handler) putElasticPool( - w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, ep rdsdriver.ElasticPools, +// 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) { @@ -342,7 +346,12 @@ func (*Handler) putElasticPool( } } - out, err := ep.CreateElasticPool(r.Context(), cfg) + write := ep.CreateElasticPool + if merge { + write = ep.UpdateElasticPool + } + + out, err := write(r.Context(), cfg) if err != nil { azurearm.WriteCErr(w, err) return @@ -440,8 +449,10 @@ func (h *Handler) serveFailoverGroup(w http.ResponseWriter, r *http.Request, rp } switch r.Method { - case http.MethodPut, http.MethodPatch: - h.putFailoverGroup(w, r, rp, fg) + 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) @@ -458,8 +469,9 @@ func (h *Handler) serveFailoverGroup(w http.ResponseWriter, r *http.Request, rp } } -func (*Handler) putFailoverGroup( - w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, fg rdsdriver.FailoverGroups, +// 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) { @@ -480,7 +492,12 @@ func (*Handler) putFailoverGroup( } } - out, err := fg.CreateFailoverGroup(r.Context(), cfg) + write := fg.CreateFailoverGroup + if merge { + write = fg.UpdateFailoverGroup + } + + out, err := write(r.Context(), cfg) if err != nil { azurearm.WriteCErr(w, err) return diff --git a/server/azure/azuresql/subresources_sdk_test.go b/server/azure/azuresql/subresources_sdk_test.go index f997f317..1137e58a 100644 --- a/server/azure/azuresql/subresources_sdk_test.go +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -385,3 +385,95 @@ func TestSDKAzureSQLManagedInstances(t *testing.T) { 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)), + }, + }, 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/resourcegraph/handler.go b/server/azure/resourcegraph/handler.go index 51a30d41..2a984cf5 100644 --- a/server/azure/resourcegraph/handler.go +++ b/server/azure/resourcegraph/handler.go @@ -265,6 +265,7 @@ var portableToAzureTypeMap = map[string]string{ //nolint:gochecknoglobals // sta "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", } diff --git a/server/azure/resourcegraph/kql.go b/server/azure/resourcegraph/kql.go index 72a57a26..8d70a783 100644 --- a/server/azure/resourcegraph/kql.go +++ b/server/azure/resourcegraph/kql.go @@ -48,6 +48,7 @@ const ( 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" ) @@ -310,6 +311,7 @@ var azureToPortableType = map[string]portableResourceType{ //nolint:gochecknoglo azureTypeAKS: {portableKubernetes, "Cluster"}, azureTypeAgentPool: {portableKubernetes, "NodeGroup"}, azureTypeSQL: {portableRelationalDB, "SqlServer"}, + azureTypeSQLMI: {portableRelationalDB, "SqlManagedInstance"}, azureTypeMySQLFlex: {portableRelationalDB, "MySqlFlexibleServer"}, azureTypePgFlex: {portableRelationalDB, "PostgresFlexibleServer"}, } diff --git a/server/azure/resourcegraph/portable_type_test.go b/server/azure/resourcegraph/portable_type_test.go index 053b7aa1..b132f5cb 100644 --- a/server/azure/resourcegraph/portable_type_test.go +++ b/server/azure/resourcegraph/portable_type_test.go @@ -15,6 +15,7 @@ func TestPortableToAzureType(t *testing.T) { {"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"}, } diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index 23eb6242..b41146f0 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -396,6 +396,7 @@ type ElasticPool struct { // 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 @@ -428,6 +429,7 @@ type FailoverGroup struct { // 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 @@ -513,6 +515,7 @@ type ManagedDatabase struct { // 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 diff --git a/services/resourcediscovery/walkers.go b/services/resourcediscovery/walkers.go index d452bb1e..1d317392 100644 --- a/services/resourcediscovery/walkers.go +++ b/services/resourcediscovery/walkers.go @@ -52,10 +52,11 @@ const ( // 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 + 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) { From 36cb365639060b777d0d7c5307ea4f11acabb396 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Wed, 29 Jul 2026 18:47:10 +0530 Subject: [PATCH 13/17] fix(azure): model Azure SQL database elastic-pool membership Address review MEDIUM #5. Databases now carry an ElasticPoolID (added to the shared InstanceConfig/Instance/ModifyInstanceInput and surfaced as the elasticPoolId ARM database property), so an SDK client placing a database into a pool round-trips instead of silently dropping it. DeleteElasticPool now returns a precondition error while the pool still contains databases, matching real Azure's 409. Covered by a membership/delete-guard test. --- providers/azure/azuresql/azuresql.go | 5 +++ providers/azure/azuresql/azuresql_test.go | 40 +++++++++++++++++++++++ providers/azure/azuresql/subresources.go | 22 +++++++++++-- server/azure/azuresql/operations.go | 17 +++++++--- server/azure/azuresql/types.go | 2 ++ services/relationaldb/driver/driver.go | 8 ++++- 6 files changed, 87 insertions(+), 7 deletions(-) diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index 788d7f6b..98773b15 100644 --- a/providers/azure/azuresql/azuresql.go +++ b/providers/azure/azuresql/azuresql.go @@ -202,6 +202,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), @@ -305,6 +306,10 @@ func (m *Mock) ModifyInstance( inst.EngineVersion = input.EngineVersion } + if input.ElasticPoolID != "" { + inst.ElasticPoolID = input.ElasticPoolID + } + if input.Tags != nil { inst.Tags = copyTags(input.Tags) } diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index 21cfe6f6..61dbb853 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -394,3 +394,43 @@ func TestManagedInstanceLifecycleAndCascade(t *testing.T) { 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) + } +} diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go index c7e4961b..057f7b47 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -2,6 +2,7 @@ package azuresql import ( "context" + "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" @@ -306,15 +307,32 @@ func (m *Mock) ListElasticPools(_ context.Context, server string) ([]rdsdriver.E return out, nil } -// DeleteElasticPool removes an elastic pool. +// 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 !m.elasticPools.Delete(subKey(server, name)) { + 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 } diff --git a/server/azure/azuresql/operations.go b/server/azure/azuresql/operations.go index bf023051..44f3d393 100644 --- a/server/azure/azuresql/operations.go +++ b/server/azure/azuresql/operations.go @@ -149,8 +149,12 @@ 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) @@ -164,6 +168,7 @@ func (h *Handler) createOrUpdateDatabase(w http.ResponseWriter, r *http.Request, 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 { @@ -189,8 +194,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) 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/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index b41146f0..5a6cbdcb 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -51,7 +51,11 @@ 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 + Tags map[string]string } // Instance describes a managed database instance. @@ -76,6 +80,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 +103,7 @@ type ModifyInstanceInput struct { DBParameterGroupName string OptionGroupName string DBClusterParameterGroupName string + ElasticPoolID string Tags map[string]string } From 69bd068291928e44c80eaf73f54e9a454bf15db3 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 30 Jul 2026 10:46:17 +0530 Subject: [PATCH 14/17] fix(gcp): real Cloud SQL read-replica and failover semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review MEDIUM #6. A Cloud SQL insert with masterInstanceName now creates an actual read replica: the replica records its master (ReadReplicaSource) and the primary lists it (replicaNames), both surfaced in the instance body. Replica lifecycle is faithful — start/stopReplica require the target to be a replica and leave it RUNNABLE (no SUSPENDED state change); promoteReplica detaches it from the primary (and rejects a non-replica); failover is a primary-only operation and rejects a replica. Covered by SDK and mock replica-lifecycle tests. --- providers/gcp/cloudsql/cloudsql.go | 23 ++++++++- providers/gcp/cloudsql/cloudsql_test.go | 32 ++++++++++++- providers/gcp/cloudsql/subresources.go | 49 ++++++++++++++++---- server/gcp/cloudsql/operations.go | 9 ++-- server/gcp/cloudsql/subresources.go | 27 +++++++++-- server/gcp/cloudsql/subresources_sdk_test.go | 47 +++++++++++++++++-- server/gcp/cloudsql/types.go | 48 ++++++++++--------- services/relationaldb/driver/driver.go | 6 ++- 8 files changed, 194 insertions(+), 47 deletions(-) diff --git a/providers/gcp/cloudsql/cloudsql.go b/providers/gcp/cloudsql/cloudsql.go index 45786e1c..d9d99f17 100644 --- a/providers/gcp/cloudsql/cloudsql.go +++ b/providers/gcp/cloudsql/cloudsql.go @@ -124,7 +124,7 @@ func copyTags(src map[string]string) map[string]string { // 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") @@ -188,6 +188,12 @@ 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) @@ -197,6 +203,21 @@ func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) ( 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() diff --git a/providers/gcp/cloudsql/cloudsql_test.go b/providers/gcp/cloudsql/cloudsql_test.go index 3fc2c781..2757866d 100644 --- a/providers/gcp/cloudsql/cloudsql_test.go +++ b/providers/gcp/cloudsql/cloudsql_test.go @@ -299,12 +299,40 @@ func TestCloudSQLReplicaAndFailoverActions(t *testing.T) { 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) } - if err := m.PromoteReplica(ctx, "i"); err != nil { - t.Errorf("PromoteReplica: %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 { diff --git a/providers/gcp/cloudsql/subresources.go b/providers/gcp/cloudsql/subresources.go index baf0ed8e..aaa64656 100644 --- a/providers/gcp/cloudsql/subresources.go +++ b/providers/gcp/cloudsql/subresources.go @@ -322,36 +322,69 @@ func (m *Mock) DeleteSslCert(_ context.Context, instance, sha1FP string) error { // ---- Instance actions ---- -// FailoverInstance validates the instance exists and re-emits metrics. Cloud -// SQL failover promotes the standby of a regional instance; the mock keeps the -// instance available. +// 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() - if _, ok := m.instances.Get(id); !ok { + 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 validates the instance exists. Cloud SQL detaches the replica -// from its primary and makes it a standalone instance; the mock keeps it -// available. +// 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() - if _, ok := m.instances.Get(id); !ok { + 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 == "" { diff --git a/server/gcp/cloudsql/operations.go b/server/gcp/cloudsql/operations.go index 66f717fb..95c2989c 100644 --- a/server/gcp/cloudsql/operations.go +++ b/server/gcp/cloudsql/operations.go @@ -13,10 +13,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 { diff --git a/server/gcp/cloudsql/subresources.go b/server/gcp/cloudsql/subresources.go index 6b40ddd8..dfbb52f8 100644 --- a/server/gcp/cloudsql/subresources.go +++ b/server/gcp/cloudsql/subresources.go @@ -449,18 +449,37 @@ func (h *Handler) promoteReplica(w http.ResponseWriter, r *http.Request, p *sqlP 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 err := h.db.StartInstance(r.Context(), p.name); err != nil { - writeErr(w, err) + if !h.requireReplica(w, r, p) { return } writeJSON(w, http.StatusOK, doneOperationWithTarget(p.project, "start-replica", "START_REPLICA", "instances", p.name)) } -func (h *Handler) stopReplica(w http.ResponseWriter, r *http.Request, p *sqlPath) { - if err := h.db.StopInstance(r.Context(), p.name); err != nil { +// 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 } diff --git a/server/gcp/cloudsql/subresources_sdk_test.go b/server/gcp/cloudsql/subresources_sdk_test.go index 656d730f..a3a0dfe7 100644 --- a/server/gcp/cloudsql/subresources_sdk_test.go +++ b/server/gcp/cloudsql/subresources_sdk_test.go @@ -143,7 +143,7 @@ func TestSDKCloudSQLInstanceActions(t *testing.T) { ctx := context.Background() mustCreateInstance(t, svc, project, "pg") - // Clone. + // 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 { @@ -154,22 +154,59 @@ func TestSDKCloudSQLInstanceActions(t *testing.T) { t.Fatalf("Get clone: %v", err) } - // Failover, stop/start replica, promote replica all succeed on a live instance. + // 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) } - if _, err := svc.Instances.StopReplica(project, "pg-clone").Context(ctx).Do(); err != nil { + // 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-clone").Context(ctx).Do(); err != nil { + if _, err := svc.Instances.StartReplica(project, "pg-replica").Context(ctx).Do(); err != nil { t.Fatalf("Instances.StartReplica: %v", err) } - if _, err := svc.Instances.PromoteReplica(project, "pg-clone").Context(ctx).Do(); err != nil { + // 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) { diff --git a/server/gcp/cloudsql/types.go b/server/gcp/cloudsql/types.go index 88a7ea6a..39cf3e4f 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 { @@ -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: "/sql/v1beta4/projects/" + project + "/instances/" + inst.ID, IPAddresses: []ipMapping{ {IPAddress: "10.0.0.1", Type: "PRIVATE"}, }, diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index 5a6cbdcb..a2dbeec2 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -55,7 +55,11 @@ type InstanceConfig struct { // pool's ARM resource ID); empty for standalone databases and non-Azure // engines. ElasticPoolID string - Tags map[string]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. From d84976be6fb5962a047cd14d9354a000d01d7ae8 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 30 Jul 2026 10:54:55 +0530 Subject: [PATCH 15/17] =?UTF-8?q?fix(sql):=20review=20LOW=20items=20?= =?UTF-8?q?=E2=80=94=20determinism,=20validation,=20replica=20fidelity=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cloud SQL backup-run IDs are generated by the mock from the clock + a monotonic counter (was time.Now().UnixNano(), violating the Clock determinism rule and collision-prone); CloneInstance now clones the source's databases and resets replica linkage; UpdateUser doc corrected. - Firewall rules validate IPv4 start/end (Azure SQL + both Flexible Servers); Azure SQL Managed Instance requires subnetId, as real Azure does. - Added a real-SDK Resource Graph indexing test that drives sqlDiscovery end-to-end (logical server + managed instance appear and filter), and a -race concurrency test that mutates a returned managed-instance Tags map to pin the copy-on-read fix. --- providers/azure/azuresql/azuresql_test.go | 46 +++++++++++++++- providers/azure/azuresql/managedinstance.go | 4 ++ providers/azure/azuresql/subresources.go | 12 ++++ providers/azure/mysqlflex/mysqlflex_test.go | 2 +- providers/azure/mysqlflex/subresources.go | 11 ++++ .../azure/postgresflex/postgresflex_test.go | 2 +- providers/azure/postgresflex/subresources.go | 11 ++++ providers/gcp/cloudsql/cloudsql.go | 27 ++++++--- providers/gcp/cloudsql/subresources.go | 25 ++++++++- .../azure/azuresql/subresources_sdk_test.go | 1 + server/azure/resourcegraph/sdk_test.go | 55 +++++++++++++++++++ server/gcp/cloudsql/operations.go | 14 +---- 12 files changed, 184 insertions(+), 26 deletions(-) diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index 61dbb853..1504a83c 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -2,6 +2,8 @@ package azuresql import ( "context" + "fmt" + "sync" "testing" "time" @@ -308,7 +310,7 @@ func TestFailoverGroupRoleFlipAndCascade(t *testing.T) { t.Error("returned slice aliased stored state") } - if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r"}); err != nil { + 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) } @@ -360,7 +362,7 @@ func TestManagedInstanceLifecycleAndCascade(t *testing.T) { m := newTestMock() ctx := context.Background() - if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi"}); err != nil { + if _, err := m.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{Name: "mi", SubnetID: "/subnets/mi"}); err != nil { t.Fatalf("CreateManagedInstance: %v", err) } @@ -434,3 +436,43 @@ func TestElasticPoolMembershipBlocksDelete(t *testing.T) { 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() +} diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go index cc94deda..91f200cc 100644 --- a/providers/azure/azuresql/managedinstance.go +++ b/providers/azure/azuresql/managedinstance.go @@ -39,6 +39,10 @@ func (m *Mock) CreateManagedInstance( 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() diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go index 057f7b47..95eb0f16 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -2,6 +2,7 @@ package azuresql import ( "context" + "net" "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" @@ -10,6 +11,13 @@ import ( 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 +} + // 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 @@ -60,6 +68,10 @@ func (m *Mock) CreateFirewallRule( 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") + } + m.mu.Lock() defer m.mu.Unlock() diff --git a/providers/azure/mysqlflex/mysqlflex_test.go b/providers/azure/mysqlflex/mysqlflex_test.go index 054584e7..51b35f2a 100644 --- a/providers/azure/mysqlflex/mysqlflex_test.go +++ b/providers/azure/mysqlflex/mysqlflex_test.go @@ -297,7 +297,7 @@ func TestDatabaseLifecycleAndCascade(t *testing.T) { t.Error("duplicate database: expected AlreadyExists") } - if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r"}); err != nil { + 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) } diff --git a/providers/azure/mysqlflex/subresources.go b/providers/azure/mysqlflex/subresources.go index 23b04cd3..32ba8abf 100644 --- a/providers/azure/mysqlflex/subresources.go +++ b/providers/azure/mysqlflex/subresources.go @@ -2,12 +2,19 @@ package mysqlflex import ( "context" + "net" 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 +} + // 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. @@ -134,6 +141,10 @@ func (m *Mock) CreateFirewallRule( 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") + } + m.mu.Lock() defer m.mu.Unlock() diff --git a/providers/azure/postgresflex/postgresflex_test.go b/providers/azure/postgresflex/postgresflex_test.go index 6aa594a3..4bf0891e 100644 --- a/providers/azure/postgresflex/postgresflex_test.go +++ b/providers/azure/postgresflex/postgresflex_test.go @@ -310,7 +310,7 @@ func TestDatabaseDefaultsAndCascade(t *testing.T) { t.Error("duplicate database: expected AlreadyExists") } - if _, err := m.CreateFirewallRule(ctx, rdsdriver.FirewallRuleConfig{Server: "srv", Name: "r"}); err != nil { + 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) } diff --git a/providers/azure/postgresflex/subresources.go b/providers/azure/postgresflex/subresources.go index 670deeb9..3612c447 100644 --- a/providers/azure/postgresflex/subresources.go +++ b/providers/azure/postgresflex/subresources.go @@ -2,11 +2,18 @@ package postgresflex import ( "context" + "net" 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 +} + // 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 @@ -140,6 +147,10 @@ func (m *Mock) CreateFirewallRule( 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") + } + m.mu.Lock() defer m.mu.Unlock() diff --git a/providers/gcp/cloudsql/cloudsql.go b/providers/gcp/cloudsql/cloudsql.go index d9d99f17..75996593 100644 --- a/providers/gcp/cloudsql/cloudsql.go +++ b/providers/gcp/cloudsql/cloudsql.go @@ -48,6 +48,10 @@ type Mock struct { 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 } @@ -429,10 +433,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() @@ -441,13 +441,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, @@ -457,7 +466,7 @@ 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 diff --git a/providers/gcp/cloudsql/subresources.go b/providers/gcp/cloudsql/subresources.go index aaa64656..61cce66d 100644 --- a/providers/gcp/cloudsql/subresources.go +++ b/providers/gcp/cloudsql/subresources.go @@ -199,8 +199,8 @@ func (m *Mock) ListUsers(_ context.Context, instance string) ([]rdsdriver.User, return out, nil } -// UpdateUser updates an existing user (host is the only mutable field the mock -// tracks). Cloud SQL's Update is idempotent create-or-update. +// 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() @@ -409,15 +409,36 @@ func (m *Mock) CloneInstance(_ context.Context, sourceID, destID string) (*rdsdr 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 := 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/server/azure/azuresql/subresources_sdk_test.go b/server/azure/azuresql/subresources_sdk_test.go index 1137e58a..9c713a3f 100644 --- a/server/azure/azuresql/subresources_sdk_test.go +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -442,6 +442,7 @@ func TestSDKAzureSQLManagedInstancePatchMerge(t *testing.T) { 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 { 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/cloudsql/operations.go b/server/gcp/cloudsql/operations.go index 95c2989c..ae47c53f 100644 --- a/server/gcp/cloudsql/operations.go +++ b/server/gcp/cloudsql/operations.go @@ -2,8 +2,6 @@ package cloudsql import ( "net/http" - "strconv" - "time" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -166,15 +164,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 From ba51c9d3f2832d03c14b81b202795896bf63078e Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 30 Jul 2026 13:26:35 +0530 Subject: [PATCH 16/17] =?UTF-8?q?fix(sql):=20address=20second=20review=20?= =?UTF-8?q?=E2=80=94=20MI=20lifecycle,=20in-place=20restore,=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cloud SQL DeleteInstance now unlinks replica<->master before cascading children, so no dangling ReadReplicaSource/ReadReplicaTargets remain. - Managed Instance lifecycle gains a state guard (transitionManagedInstance): Start/Stop respect current state, Failover requires Ready; MI create emits representative metrics and clones returned Tags; adds CreateManagedInstance cost rate. - Cloud SQL RestoreBackup restores in place onto the existing instance via a new optional BackupRestorer capability (was create-new -> 409). - Failover-group failover requires a partner server; firewall rules validate Start <= End; Azure Flex SetConfiguration rejects unknown params and empty values; ARM parser captures the action verb so forced vs planned failover and unknown POST verbs are distinguished; Cloud SQL selfLink/targetLink use the served /v1 prefix. - Broaden new-package test coverage (sub-resource CRUD, MI lifecycle, Users update, backup get, error paths, server PATCH, raw MI start/stop). --- providers/azure/azuresql/azuresql_test.go | 335 ++++++++++++++++++ providers/azure/azuresql/managedinstance.go | 77 +++- providers/azure/azuresql/subresources.go | 19 + providers/azure/mysqlflex/mysqlflex_test.go | 87 ++++- providers/azure/mysqlflex/subresources.go | 31 ++ .../azure/postgresflex/postgresflex_test.go | 87 ++++- providers/azure/postgresflex/subresources.go | 31 ++ providers/gcp/cloudsql/cloudsql.go | 66 +++- providers/gcp/cloudsql/cloudsql_test.go | 193 ++++++++++ server/azure/azuresql/handler.go | 3 + .../azuresql/managedinstance_raw_test.go | 52 +++ server/azure/azuresql/sdk_roundtrip_test.go | 24 ++ server/azure/azuresql/subresources.go | 10 + .../azure/azuresql/subresources_sdk_test.go | 17 + server/gcp/cloudsql/handler.go | 10 +- server/gcp/cloudsql/operations.go | 14 +- server/gcp/cloudsql/sdk_roundtrip_test.go | 29 +- server/gcp/cloudsql/subresources_sdk_test.go | 37 ++ server/gcp/cloudsql/types.go | 4 +- server/wire/azurearm/azurearm.go | 28 +- server/wire/azurearm/azurearm_test.go | 13 + services/cost/cost.go | 1 + services/relationaldb/driver/driver.go | 9 + 23 files changed, 1127 insertions(+), 50 deletions(-) create mode 100644 server/azure/azuresql/managedinstance_raw_test.go diff --git a/providers/azure/azuresql/azuresql_test.go b/providers/azure/azuresql/azuresql_test.go index 1504a83c..97d6b830 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -274,6 +274,47 @@ func TestSubResourcesRequireServer(t *testing.T) { } } +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() @@ -476,3 +517,297 @@ func TestConcurrentSubResourceAccess(t *testing.T) { 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) + } +} diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go index 91f200cc..369d6f5e 100644 --- a/providers/azure/azuresql/managedinstance.go +++ b/providers/azure/azuresql/managedinstance.go @@ -6,6 +6,7 @@ import ( 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" ) @@ -19,6 +20,9 @@ const ( miDefaultTier = "GeneralPurpose" miDefaultVCores = 4 miDefaultStorage = 32 + + miStateReady = "Ready" + miStateStopped = "Stopped" ) func (m *Mock) miARN(name string) string { @@ -60,7 +64,7 @@ func (m *Mock) CreateManagedInstance( SubnetID: cfg.SubnetID, VCores: orDefaultInt(cfg.VCores, miDefaultVCores), StorageGB: orDefaultInt(cfg.StorageGB, miDefaultStorage), - State: "Ready", + State: miStateReady, FQDN: cfg.Name + ".managed.database.windows.net", ARN: m.miARN(cfg.Name), Tags: copyTags(cfg.Tags), @@ -68,11 +72,34 @@ func (m *Mock) CreateManagedInstance( 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). // @@ -165,23 +192,42 @@ func (m *Mock) DeleteManagedInstance(_ context.Context, name string) error { return nil } -// StartManagedInstance marks a managed instance ready. +// StartManagedInstance moves a stopped managed instance back to ready. func (m *Mock) StartManagedInstance(ctx context.Context, name string) error { - return m.setManagedInstanceState(ctx, name, "Ready") + return m.transitionManagedInstance(ctx, name, miStateStopped, miStateReady, "start") } -// StopManagedInstance marks a managed instance stopped. +// StopManagedInstance moves a ready managed instance to stopped. func (m *Mock) StopManagedInstance(ctx context.Context, name string) error { - return m.setManagedInstanceState(ctx, name, "Stopped") + return m.transitionManagedInstance(ctx, name, miStateReady, miStateStopped, "stop") } -// FailoverManagedInstance triggers a managed-instance failover; the instance -// stays ready. -func (m *Mock) FailoverManagedInstance(ctx context.Context, name string) error { - return m.setManagedInstanceState(ctx, name, "Ready") +// 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 } -func (m *Mock) setManagedInstanceState(_ context.Context, name, state string) error { +// 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() @@ -190,7 +236,16 @@ func (m *Mock) setManagedInstanceState(_ context.Context, name, state string) er return cerrors.Newf(cerrors.NotFound, "managed instance %q not found", name) } - mi.State = state + 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 diff --git a/providers/azure/azuresql/subresources.go b/providers/azure/azuresql/subresources.go index 95eb0f16..4652467d 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -1,6 +1,7 @@ package azuresql import ( + "bytes" "context" "net" "strings" @@ -18,6 +19,12 @@ func validIPv4(s string) bool { 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 @@ -72,6 +79,10 @@ func (m *Mock) CreateFirewallRule( 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() @@ -489,6 +500,14 @@ func (m *Mock) FailoverFailoverGroup(_ context.Context, server, name string) (*r 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 { diff --git a/providers/azure/mysqlflex/mysqlflex_test.go b/providers/azure/mysqlflex/mysqlflex_test.go index 51b35f2a..97d692be 100644 --- a/providers/azure/mysqlflex/mysqlflex_test.go +++ b/providers/azure/mysqlflex/mysqlflex_test.go @@ -276,7 +276,7 @@ func TestSubResourcesRequireServer(t *testing.T) { t.Error("CreateFirewallRule on missing server: expected error") } - if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "ghost", Name: "k"}); err == nil { + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "ghost", Name: "max_connections", Value: "100"}); err == nil { t.Error("SetConfiguration on missing server: expected error") } } @@ -301,7 +301,7 @@ func TestDatabaseLifecycleAndCascade(t *testing.T) { t.Fatalf("CreateFirewallRule: %v", err) } - if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "k", Value: "v"}); err != nil { + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "100"}); err != nil { t.Fatalf("SetConfiguration: %v", err) } @@ -315,6 +315,29 @@ func TestDatabaseLifecycleAndCascade(t *testing.T) { } } +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) + } +} + func TestFailoverRequiresRunning(t *testing.T) { m := newTestMock() ctx := context.Background() @@ -339,3 +362,63 @@ func TestFailoverRequiresRunning(t *testing.T) { 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) + } + + if cs, err := m.ListConfigurations(ctx, "srv"); err != nil || len(cs) != 1 { + t.Fatalf("ListConfigurations: %d %v", len(cs), err) + } +} diff --git a/providers/azure/mysqlflex/subresources.go b/providers/azure/mysqlflex/subresources.go index 32ba8abf..46acbf9f 100644 --- a/providers/azure/mysqlflex/subresources.go +++ b/providers/azure/mysqlflex/subresources.go @@ -27,6 +27,29 @@ var ( const defaultCollation = "utf8mb4_general_ci" +// knownServerParameters is a representative subset of the MySQL Flexible Server +// parameter catalog. 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. +// +//nolint:gochecknoglobals // immutable parameter-name lookup table. +var knownServerParameters = map[string]bool{ + "max_connections": true, + "wait_timeout": true, + "interactive_timeout": true, + "slow_query_log": true, + "long_query_time": true, + "innodb_buffer_pool_size": true, + "character_set_server": true, + "collation_server": true, + "time_zone": true, + "sql_mode": true, + "event_scheduler": true, + "log_bin_trust_function_creators": true, + "max_allowed_packet": true, + "innodb_lock_wait_timeout": true, +} + func childKey(server, name string) string { return server + "/" + name } func (m *Mock) childARN(server, subType, name string) string { @@ -225,6 +248,14 @@ func (m *Mock) SetConfiguration( return nil, cerrors.New(cerrors.InvalidArgument, "configuration name is required") } + if !knownServerParameters[cfg.Name] { + 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() diff --git a/providers/azure/postgresflex/postgresflex_test.go b/providers/azure/postgresflex/postgresflex_test.go index 4bf0891e..2f156a6a 100644 --- a/providers/azure/postgresflex/postgresflex_test.go +++ b/providers/azure/postgresflex/postgresflex_test.go @@ -284,7 +284,7 @@ func TestSubResourcesRequireServer(t *testing.T) { t.Error("CreateFirewallRule on missing server: expected error") } - if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "ghost", Name: "k"}); err == nil { + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "ghost", Name: "max_connections", Value: "100"}); err == nil { t.Error("SetConfiguration on missing server: expected error") } } @@ -314,7 +314,7 @@ func TestDatabaseDefaultsAndCascade(t *testing.T) { t.Fatalf("CreateFirewallRule: %v", err) } - if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "k", Value: "v"}); err != nil { + if _, err := m.SetConfiguration(ctx, rdsdriver.ConfigurationConfig{Server: "srv", Name: "max_connections", Value: "100"}); err != nil { t.Fatalf("SetConfiguration: %v", err) } @@ -326,3 +326,86 @@ func TestDatabaseDefaultsAndCascade(t *testing.T) { 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) + } +} + +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) + } + + if cs, err := m.ListConfigurations(ctx, "srv"); err != nil || len(cs) != 1 { + t.Fatalf("ListConfigurations: %d %v", len(cs), err) + } +} diff --git a/providers/azure/postgresflex/subresources.go b/providers/azure/postgresflex/subresources.go index 3612c447..2edca324 100644 --- a/providers/azure/postgresflex/subresources.go +++ b/providers/azure/postgresflex/subresources.go @@ -29,6 +29,29 @@ const ( defaultCollation = "en_US.utf8" ) +// knownServerParameters is a representative subset of the PostgreSQL Flexible +// Server parameter catalog. 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. +// +//nolint:gochecknoglobals // immutable parameter-name lookup table. +var knownServerParameters = map[string]bool{ + "max_connections": true, + "shared_buffers": true, + "work_mem": true, + "maintenance_work_mem": true, + "effective_cache_size": true, + "log_statement": true, + "log_min_duration_statement": true, + "autovacuum": true, + "statement_timeout": true, + "timezone": true, + "max_wal_size": true, + "wal_level": true, + "max_prepared_transactions": true, + "idle_in_transaction_session_timeout": true, +} + func childKey(server, name string) string { return server + "/" + name } func (m *Mock) childARN(server, subType, name string) string { @@ -231,6 +254,14 @@ func (m *Mock) SetConfiguration( return nil, cerrors.New(cerrors.InvalidArgument, "configuration name is required") } + if !knownServerParameters[cfg.Name] { + 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() diff --git a/providers/gcp/cloudsql/cloudsql.go b/providers/gcp/cloudsql/cloudsql.go index 75996593..e21edd3b 100644 --- a/providers/gcp/cloudsql/cloudsql.go +++ b/providers/gcp/cloudsql/cloudsql.go @@ -36,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 @@ -294,20 +296,45 @@ func (m *Mock) ModifyInstance( 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) { @@ -567,6 +594,41 @@ func (m *Mock) RestoreInstanceFromSnapshot( 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 := inst + out.Tags = copyTags(inst.Tags) + + return &out, nil +} + // CreateClusterSnapshot is unsupported on Cloud SQL. func (*Mock) CreateClusterSnapshot( _ context.Context, _ rdsdriver.ClusterSnapshotConfig, diff --git a/providers/gcp/cloudsql/cloudsql_test.go b/providers/gcp/cloudsql/cloudsql_test.go index 2757866d..0dee031a 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() @@ -339,3 +383,152 @@ func TestCloudSQLReplicaAndFailoverActions(t *testing.T) { 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") + } +} diff --git a/server/azure/azuresql/handler.go b/server/azure/azuresql/handler.go index dc7c8856..cd10ee45 100644 --- a/server/azure/azuresql/handler.go +++ b/server/azure/azuresql/handler.go @@ -42,6 +42,9 @@ const ( subMIStart = "start" subMIStop = "stop" subMIFailover = "failover" + + actionFailover = "failover" + actionForceFailover = "forceFailoverAllowDataLoss" ) // Handler serves Microsoft.Sql ARM requests against a relationaldb driver. 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/sdk_roundtrip_test.go b/server/azure/azuresql/sdk_roundtrip_test.go index f895fce0..867f308c 100644 --- a/server/azure/azuresql/sdk_roundtrip_test.go +++ b/server/azure/azuresql/sdk_roundtrip_test.go @@ -23,6 +23,18 @@ func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcor return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil } +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() @@ -114,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 index dd094e86..9cc731c9 100644 --- a/server/azure/azuresql/subresources.go +++ b/server/azure/azuresql/subresources.go @@ -509,6 +509,16 @@ func (*Handler) writeFailoverGroup( 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) diff --git a/server/azure/azuresql/subresources_sdk_test.go b/server/azure/azuresql/subresources_sdk_test.go index 9c713a3f..ff26c4da 100644 --- a/server/azure/azuresql/subresources_sdk_test.go +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -224,6 +224,23 @@ func TestSDKAzureSQLFailoverGroups(t *testing.T) { 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) + } + delPoller, err := fg.BeginDelete(ctx, "rg-1", "srv1", "fg1", nil) if err != nil { t.Fatalf("BeginDelete: %v", err) diff --git a/server/gcp/cloudsql/handler.go b/server/gcp/cloudsql/handler.go index d48788f8..e727d81d 100644 --- a/server/gcp/cloudsql/handler.go +++ b/server/gcp/cloudsql/handler.go @@ -103,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" diff --git a/server/gcp/cloudsql/operations.go b/server/gcp/cloudsql/operations.go index ae47c53f..85b388a2 100644 --- a/server/gcp/cloudsql/operations.go +++ b/server/gcp/cloudsql/operations.go @@ -3,6 +3,7 @@ package cloudsql import ( "net/http" + cerrors "github.com/stackshy/cloudemu/v2/errors" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -147,13 +148,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 } 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_sdk_test.go b/server/gcp/cloudsql/subresources_sdk_test.go index a3a0dfe7..8ff537ca 100644 --- a/server/gcp/cloudsql/subresources_sdk_test.go +++ b/server/gcp/cloudsql/subresources_sdk_test.go @@ -88,6 +88,13 @@ func TestSDKCloudSQLUsers(t *testing.T) { 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) } @@ -246,3 +253,33 @@ func TestSDKCloudSQLTiersAndFlags(t *testing.T) { 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 39cf3e4f..5531f4b0 100644 --- a/server/gcp/cloudsql/types.go +++ b/server/gcp/cloudsql/types.go @@ -112,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 } @@ -130,7 +130,7 @@ func toSQLInstance(inst *rdsdriver.Instance, project string) sqlInstance { ConnectionName: inst.Endpoint, MasterInstanceName: inst.ReadReplicaSource, ReplicaNames: inst.ReadReplicaTargets, - SelfLink: "/sql/v1beta4/projects/" + project + "/instances/" + inst.ID, + 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 8acca82c..516e98b5 100644 --- a/services/cost/cost.go +++ b/services/cost/cost.go @@ -59,6 +59,7 @@ func defaultRates() map[string]float64 { "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 a2dbeec2..2156fe68 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -761,6 +761,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" From 267ea845b6773c8674dacaf980003349ba0b7060 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 30 Jul 2026 21:43:28 +0530 Subject: [PATCH 17/17] =?UTF-8?q?fix(sql):=20address=20third=20review=20?= =?UTF-8?q?=E2=80=94=20output=20aliasing,=20config=20catalog,=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deep-copy Tags/slice fields on every Instance/Cluster/Snapshot return path in azuresql and cloudsql (Describe/Create/Modify/Restore/Clone), so a returned value never aliases the memstore — fixes a potential concurrent-map crash and matches the managedinstance/FailoverGroup discipline. - Azure Flex GetConfiguration/ListConfigurations return catalog defaults for a known-but-unset parameter (real Azure behavior) instead of NotFound/empty; the catalog now maps names to defaults. - MySQL Flex batch config update is atomic: all entries are validated before any is applied, via a new optional BatchConfigurations capability. - Azure SQL database create/modify validates the referenced elastic pool exists (symmetry with the pool-delete member guard). - Cloud SQL rejects child (database/user) names containing '/' that would orphan a row; get-paths add length guards on single-ID Describe lookups. - Flex firewall create validates start <= end (parity with Azure SQL). - Tests: aliasing, config defaults, batch atomicity, elastic-pool validation, child-name rejection, FG update/list, managed-database delete, and Azure wire-level error-mapping (404/400) across all three Azure SQL services. --- providers/azure/azuresql/azuresql.go | 60 +++++- providers/azure/azuresql/azuresql_test.go | 81 ++++++++ providers/azure/azuresql/subresources.go | 26 +++ providers/azure/mysqlflex/mysqlflex_test.go | 61 +++++- providers/azure/mysqlflex/subresources.go | 189 +++++++++++++----- .../azure/postgresflex/postgresflex_test.go | 18 +- providers/azure/postgresflex/subresources.go | 105 +++++++--- providers/gcp/cloudsql/cloudsql.go | 46 ++++- providers/gcp/cloudsql/cloudsql_test.go | 48 +++++ providers/gcp/cloudsql/subresources.go | 23 ++- server/azure/azuresql/error_paths_test.go | 63 ++++++ server/azure/azuresql/operations.go | 10 + .../azure/azuresql/subresources_sdk_test.go | 41 ++++ server/azure/mysqlflex/error_paths_test.go | 65 ++++++ server/azure/mysqlflex/operations.go | 16 +- server/azure/mysqlflex/subresources.go | 20 +- .../azure/mysqlflex/subresources_sdk_test.go | 21 +- server/azure/postgresflex/error_paths_test.go | 66 ++++++ server/azure/postgresflex/operations.go | 5 + .../postgresflex/subresources_sdk_test.go | 17 +- server/gcp/cloudsql/operations.go | 5 + services/relationaldb/driver/driver.go | 8 + 22 files changed, 882 insertions(+), 112 deletions(-) create mode 100644 server/azure/azuresql/error_paths_test.go create mode 100644 server/azure/mysqlflex/error_paths_test.go create mode 100644 server/azure/postgresflex/error_paths_test.go diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index 98773b15..77fc71be 100644 --- a/providers/azure/azuresql/azuresql.go +++ b/providers/azure/azuresql/azuresql.go @@ -148,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. @@ -175,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 @@ -215,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 } @@ -234,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 @@ -248,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 @@ -307,6 +341,10 @@ func (m *Mock) ModifyInstance( } if input.ElasticPoolID != "" { + if err := m.requireElasticPool(inst.ClusterID, input.ElasticPoolID); err != nil { + return nil, err + } + inst.ElasticPoolID = input.ElasticPoolID } @@ -316,7 +354,7 @@ func (m *Mock) ModifyInstance( m.instances.Set(instanceKey(inst.ClusterID, inst.ID), inst) - out := inst + out := cloneInstance(inst) return &out, nil } @@ -432,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 } @@ -448,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 @@ -462,7 +500,7 @@ 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 @@ -492,7 +530,7 @@ func (m *Mock) ModifyCluster( m.clusters.Set(id, cluster) - out := cluster + out := cloneCluster(cluster) return &out, nil } @@ -591,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 } @@ -620,7 +658,7 @@ func (m *Mock) DescribeSnapshots( } } - out = append(out, snap) + out = append(out, cloneSnapshot(snap)) } return out, nil @@ -707,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 97d6b830..5dac686c 100644 --- a/providers/azure/azuresql/azuresql_test.go +++ b/providers/azure/azuresql/azuresql_test.go @@ -811,3 +811,84 @@ func TestDatabaseAndManagedInstanceEmitMetrics(t *testing.T) { 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/subresources.go b/providers/azure/azuresql/subresources.go index 4652467d..68531bd5 100644 --- a/providers/azure/azuresql/subresources.go +++ b/providers/azure/azuresql/subresources.go @@ -45,6 +45,32 @@ const ( 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) } diff --git a/providers/azure/mysqlflex/mysqlflex_test.go b/providers/azure/mysqlflex/mysqlflex_test.go index 97d692be..9a6a64f2 100644 --- a/providers/azure/mysqlflex/mysqlflex_test.go +++ b/providers/azure/mysqlflex/mysqlflex_test.go @@ -336,6 +336,21 @@ func TestSetConfigurationValidatesParameter(t *testing.T) { 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) { @@ -418,7 +433,51 @@ func TestSubResourceCRUDCoverage(t *testing.T) { t.Fatalf("GetConfiguration: %+v %v", got, err) } - if cs, err := m.ListConfigurations(ctx, "srv"); err != nil || len(cs) != 1 { + // 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 index 46acbf9f..b91a47c5 100644 --- a/providers/azure/mysqlflex/subresources.go +++ b/providers/azure/mysqlflex/subresources.go @@ -1,8 +1,10 @@ package mysqlflex import ( + "bytes" "context" "net" + "sort" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" @@ -15,39 +17,47 @@ func validIPv4(s string) bool { 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.Failover = (*Mock)(nil) + _ 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. 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. +// 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]bool{ - "max_connections": true, - "wait_timeout": true, - "interactive_timeout": true, - "slow_query_log": true, - "long_query_time": true, - "innodb_buffer_pool_size": true, - "character_set_server": true, - "collation_server": true, - "time_zone": true, - "sql_mode": true, - "event_scheduler": true, - "log_bin_trust_function_creators": true, - "max_allowed_packet": true, - "innodb_lock_wait_timeout": true, +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 } @@ -168,6 +178,10 @@ func (m *Mock) CreateFirewallRule( 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() @@ -239,30 +253,28 @@ func (m *Mock) DeleteFirewallRule(_ context.Context, server, name string) error // ---- 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) { +// 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 nil, cerrors.New(cerrors.InvalidArgument, "configuration name is required") + return cerrors.New(cerrors.InvalidArgument, "configuration name is required") } - if !knownServerParameters[cfg.Name] { - return nil, cerrors.Newf(cerrors.NotFound, "unknown server parameter %q", cfg.Name) + if _, known := knownServerParameters[cfg.Name]; !known { + return cerrors.Newf(cerrors.NotFound, "unknown server parameter %q", cfg.Name) } if cfg.Value == "" { - return nil, cerrors.New(cerrors.InvalidArgument, "configuration value is required") + return 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 - } + 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) @@ -280,27 +292,94 @@ func (m *Mock) SetConfiguration( m.configurations.Set(key, conf) - out := 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 } -// GetConfiguration returns a server parameter. +// 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() - conf, ok := m.configurations.Get(childKey(server, name)) - if !ok { + 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) } - out := conf + return m.defaultConfiguration(server, name, def), nil +} - return &out, 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 parameters that have been set on a server. +// 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() @@ -309,15 +388,33 @@ func (m *Mock) ListConfigurations(_ context.Context, server string) ([]rdsdriver return nil, err } - out := []rdsdriver.Configuration{} + overrides := make(map[string]rdsdriver.Configuration) confs := m.configurations.SortedValues() for i := range confs { if confs[i].Server == server { - out = append(out, confs[i]) + 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/azure/postgresflex/postgresflex_test.go b/providers/azure/postgresflex/postgresflex_test.go index 2f156a6a..0c73a806 100644 --- a/providers/azure/postgresflex/postgresflex_test.go +++ b/providers/azure/postgresflex/postgresflex_test.go @@ -348,6 +348,20 @@ func TestSetConfigurationValidatesParameter(t *testing.T) { 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) { @@ -405,7 +419,9 @@ func TestSubResourceCRUDCoverage(t *testing.T) { t.Fatalf("GetConfiguration: %+v %v", got, err) } - if cs, err := m.ListConfigurations(ctx, "srv"); err != nil || len(cs) != 1 { + // 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 index 2edca324..bb264e38 100644 --- a/providers/azure/postgresflex/subresources.go +++ b/providers/azure/postgresflex/subresources.go @@ -1,8 +1,10 @@ package postgresflex import ( + "bytes" "context" "net" + "sort" cerrors "github.com/stackshy/cloudemu/v2/errors" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" @@ -14,6 +16,12 @@ func validIPv4(s string) bool { 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 @@ -30,26 +38,27 @@ const ( ) // knownServerParameters is a representative subset of the PostgreSQL Flexible -// Server parameter catalog. 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. +// 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]bool{ - "max_connections": true, - "shared_buffers": true, - "work_mem": true, - "maintenance_work_mem": true, - "effective_cache_size": true, - "log_statement": true, - "log_min_duration_statement": true, - "autovacuum": true, - "statement_timeout": true, - "timezone": true, - "max_wal_size": true, - "wal_level": true, - "max_prepared_transactions": true, - "idle_in_transaction_session_timeout": true, +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 } @@ -174,6 +183,10 @@ func (m *Mock) CreateFirewallRule( 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() @@ -254,7 +267,7 @@ func (m *Mock) SetConfiguration( return nil, cerrors.New(cerrors.InvalidArgument, "configuration name is required") } - if !knownServerParameters[cfg.Name] { + if _, known := knownServerParameters[cfg.Name]; !known { return nil, cerrors.Newf(cerrors.NotFound, "unknown server parameter %q", cfg.Name) } @@ -291,22 +304,42 @@ func (m *Mock) SetConfiguration( return &out, nil } -// GetConfiguration returns a server parameter. +// 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() - conf, ok := m.configurations.Get(childKey(server, name)) - if !ok { + 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) } - out := conf + return m.defaultConfiguration(server, name, def), nil +} - return &out, 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 parameters that have been set on a server. +// 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() @@ -315,13 +348,31 @@ func (m *Mock) ListConfigurations(_ context.Context, server string) ([]rdsdriver return nil, err } - out := []rdsdriver.Configuration{} + overrides := make(map[string]rdsdriver.Configuration) confs := m.configurations.SortedValues() for i := range confs { if confs[i].Server == server { - out = append(out, confs[i]) + 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 e21edd3b..10ae5500 100644 --- a/providers/gcp/cloudsql/cloudsql.go +++ b/providers/gcp/cloudsql/cloudsql.go @@ -128,6 +128,35 @@ 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,gocyclo // cfg matches the driver signature; linear field-defaulting plus optional replica linking. @@ -204,7 +233,7 @@ func (m *Mock) CreateInstance(_ context.Context, cfg rdsdriver.InstanceConfig) ( m.emitInstanceMetrics(cfg.ID, cpuMetricRunning, connRunning) - out := inst + out := cloneInstance(inst) return &out, nil } @@ -235,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 @@ -249,7 +278,7 @@ 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 @@ -291,7 +320,7 @@ func (m *Mock) ModifyInstance( m.instances.Set(id, inst) - out := inst + out := cloneInstance(inst) return &out, nil } @@ -495,7 +524,7 @@ func (m *Mock) CreateSnapshot(_ context.Context, cfg rdsdriver.SnapshotConfig) ( m.snapshots.Set(id, snap) - out := snap + out := cloneSnapshot(snap) return &out, nil } @@ -524,7 +553,7 @@ func (m *Mock) DescribeSnapshots( } } - out = append(out, snap) + out = append(out, cloneSnapshot(snap)) } return out, nil @@ -589,7 +618,7 @@ func (m *Mock) RestoreInstanceFromSnapshot( m.emitInstanceMetrics(input.NewInstanceID, cpuMetricRunning, connRunning) - out := inst + out := cloneInstance(inst) return &out, nil } @@ -623,8 +652,7 @@ func (m *Mock) RestoreBackup( m.instances.Set(targetInstanceID, inst) m.emitInstanceMetrics(targetInstanceID, cpuMetricRunning, connRunning) - out := inst - out.Tags = copyTags(inst.Tags) + out := cloneInstance(inst) return &out, nil } diff --git a/providers/gcp/cloudsql/cloudsql_test.go b/providers/gcp/cloudsql/cloudsql_test.go index 0dee031a..bb1674c1 100644 --- a/providers/gcp/cloudsql/cloudsql_test.go +++ b/providers/gcp/cloudsql/cloudsql_test.go @@ -532,3 +532,51 @@ func TestClusterOpsAndMonitoringCoverage(t *testing.T) { 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 index 61cce66d..d55d05cd 100644 --- a/providers/gcp/cloudsql/subresources.go +++ b/providers/gcp/cloudsql/subresources.go @@ -4,6 +4,7 @@ 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" @@ -31,6 +32,18 @@ const ( 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) @@ -47,6 +60,10 @@ func (m *Mock) CreateDatabase(_ context.Context, cfg rdsdriver.DatabaseConfig) ( 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() @@ -139,6 +156,10 @@ func (m *Mock) CreateUser(_ context.Context, cfg rdsdriver.UserConfig) (*rdsdriv 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() @@ -422,7 +443,7 @@ func (m *Mock) CloneInstance(_ context.Context, sourceID, destID string) (*rdsdr m.emitInstanceMetrics(destID, cpuMetricRunning, connRunning) - out := clone + out := cloneInstance(clone) 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/operations.go b/server/azure/azuresql/operations.go index 44f3d393..b049ea8d 100644 --- a/server/azure/azuresql/operations.go +++ b/server/azure/azuresql/operations.go @@ -76,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)) } @@ -218,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/subresources_sdk_test.go b/server/azure/azuresql/subresources_sdk_test.go index ff26c4da..24f4ec1b 100644 --- a/server/azure/azuresql/subresources_sdk_test.go +++ b/server/azure/azuresql/subresources_sdk_test.go @@ -241,6 +241,33 @@ func TestSDKAzureSQLFailoverGroups(t *testing.T) { 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) @@ -388,6 +415,20 @@ func TestSDKAzureSQLManagedInstances(t *testing.T) { 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 { 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/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/subresources.go b/server/azure/mysqlflex/subresources.go index e2ddf6e5..bd1232f1 100644 --- a/server/azure/mysqlflex/subresources.go +++ b/server/azure/mysqlflex/subresources.go @@ -394,16 +394,28 @@ func (h *Handler) batchUpdateConfigurations(w http.ResponseWriter, r *http.Reque 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 } - if _, err := cf.SetConfiguration(r.Context(), cfg); err != nil { - azurearm.WriteCErr(w, err) - return - } + 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) diff --git a/server/azure/mysqlflex/subresources_sdk_test.go b/server/azure/mysqlflex/subresources_sdk_test.go index 861fbcf4..f985b151 100644 --- a/server/azure/mysqlflex/subresources_sdk_test.go +++ b/server/azure/mysqlflex/subresources_sdk_test.go @@ -186,8 +186,25 @@ func TestSDKMySQLFlexConfigurations(t *testing.T) { t.Fatalf("List: %v", err) } - if len(page.Value) != 2 { - t.Fatalf("got %d configurations, want 2", len(page.Value)) + // 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"]) } } 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/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/subresources_sdk_test.go b/server/azure/postgresflex/subresources_sdk_test.go index 384328c2..92fc2f44 100644 --- a/server/azure/postgresflex/subresources_sdk_test.go +++ b/server/azure/postgresflex/subresources_sdk_test.go @@ -189,7 +189,20 @@ func TestSDKPostgresFlexConfigurations(t *testing.T) { t.Fatalf("List: %v", err) } - if len(page.Value) != 1 { - t.Fatalf("got %d configurations, want 1", len(page.Value)) + // 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/gcp/cloudsql/operations.go b/server/gcp/cloudsql/operations.go index 85b388a2..2f38e845 100644 --- a/server/gcp/cloudsql/operations.go +++ b/server/gcp/cloudsql/operations.go @@ -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)) } diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index 2156fe68..c1065fe7 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -345,6 +345,14 @@ type Configurations interface { 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 {