Skip to content

Commit ead90ef

Browse files
authored
feat(resource_user): support ALTER USER resource limits (#269)
* feat(resource_user): support ALTER USER resource limits (MAX_USER_CONNECTIONS, MAX_STATEMENT_TIME) - Add max_user_connections and max_statement_time attributes - Implement ALTER USER WITH syntax for in-place limit updates - Add MariaDB version validation for MAX_STATEMENT_TIME - Reset limits to 0 when attributes removed from config - Support fractional seconds for MAX_STATEMENT_TIME Enables managing user resource limits without user recreation. * fix(resource_user): add MySQL 5.6 compatibility for user resource limits * fix(resource_user): tidb tests validates * chore(resource_user): implement changes from pr
1 parent 8f41f01 commit ead90ef

File tree

5 files changed

+570
-0
lines changed

5 files changed

+570
-0
lines changed

mysql/provider.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,15 @@ func serverRds(db *sql.DB) (bool, error) {
944944
return false, nil
945945
}
946946

947+
func serverMariaDB(db *sql.DB) (bool, error) {
948+
versionString, err := serverVersionString(db)
949+
if err != nil {
950+
return false, err
951+
}
952+
953+
return strings.Contains(versionString, "MariaDB"), nil
954+
}
955+
947956
func connectToMySQL(ctx context.Context, conf *MySQLConfiguration) (*sql.DB, error) {
948957
conn, err := connectToMySQLInternal(ctx, conf)
949958
if err != nil {

mysql/provider_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,27 @@ func testAccPreCheckSkipMariaDB(t *testing.T) {
274274
}
275275
}
276276

277+
func testAccPreCheckRequireMariaDB(t *testing.T) {
278+
testAccPreCheck(t)
279+
280+
ctx := context.Background()
281+
db, err := connectToMySQL(ctx, testAccProvider.Meta().(*MySQLConfiguration))
282+
if err != nil {
283+
t.Fatalf("Cannot connect to DB (RequireMariaDB): %v", err)
284+
return
285+
}
286+
287+
currentVersionString, err := serverVersionString(db)
288+
if err != nil {
289+
t.Fatalf("Cannot get DB version string (RequireMariaDB): %v", err)
290+
return
291+
}
292+
293+
if !strings.Contains(currentVersionString, "MariaDB") {
294+
t.Skip("Test requires MariaDB")
295+
}
296+
}
297+
277298
func testAccPreCheckSkipNotMySQL8(t *testing.T) {
278299
testAccPreCheckSkipNotMySQLVersionMin(t, "8.0.0")
279300
}

mysql/resource_user.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log"
88
"regexp"
9+
"strconv"
910
"strings"
1011

1112
"github.com/hashicorp/go-cty/cty"
@@ -44,6 +45,24 @@ func resourceUser() *schema.Resource {
4445
StateContext: ImportUser,
4546
},
4647

48+
CustomizeDiff: func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) error {
49+
// Validate max_user_connections is not set on TiDB
50+
if _, ok := d.GetOk("max_user_connections"); ok {
51+
if err := checkMaxUserConnectionsSupport(ctx, meta); err != nil {
52+
return err
53+
}
54+
}
55+
56+
// Validate max_statement_time is not set on non-MariaDB
57+
if _, ok := d.GetOk("max_statement_time"); ok {
58+
if err := checkMaxStatementTimeSupport(ctx, meta); err != nil {
59+
return err
60+
}
61+
}
62+
63+
return nil
64+
},
65+
4766
Schema: map[string]*schema.Schema{
4867
"user": {
4968
Type: schema.TypeString,
@@ -154,6 +173,20 @@ func resourceUser() *schema.Resource {
154173
Optional: true,
155174
Default: false,
156175
},
176+
177+
"max_user_connections": {
178+
Type: schema.TypeInt,
179+
Optional: true,
180+
ValidateFunc: validation.IntAtLeast(0),
181+
Description: "Maximum number of simultaneous connections for the user (0 = unlimited). Supported on MySQL 5.0+ and MariaDB.",
182+
},
183+
184+
"max_statement_time": {
185+
Type: schema.TypeFloat,
186+
Optional: true,
187+
ValidateFunc: validation.FloatAtLeast(0),
188+
Description: "Maximum execution time for statements in seconds (0 = unlimited). Supports fractional values (e.g., 0.01 for 10ms, 30.5 for 30.5s). Only supported on MariaDB 10.1.1+, not MySQL.",
189+
},
157190
},
158191
}
159192
}
@@ -174,6 +207,50 @@ func checkDiscardOldPasswordSupport(ctx context.Context, meta interface{}) error
174207
return nil
175208
}
176209

210+
func checkMaxUserConnectionsSupport(ctx context.Context, meta interface{}) error {
211+
db, err := getDatabaseFromMeta(ctx, meta)
212+
if err != nil {
213+
return err
214+
}
215+
216+
isTiDB, _, _, err := serverTiDB(db)
217+
if err != nil {
218+
return err
219+
}
220+
221+
if isTiDB {
222+
return errors.New("MAX_USER_CONNECTIONS is not supported on TiDB")
223+
}
224+
225+
return nil
226+
}
227+
228+
func checkMaxStatementTimeSupport(ctx context.Context, meta interface{}) error {
229+
db, err := getDatabaseFromMeta(ctx, meta)
230+
if err != nil {
231+
return err
232+
}
233+
234+
isMariaDB, err := serverMariaDB(db)
235+
if err != nil {
236+
return err
237+
}
238+
239+
if !isMariaDB {
240+
return errors.New("MAX_STATEMENT_TIME is only supported on MariaDB 10.1.1+, not MySQL")
241+
}
242+
243+
// Check MariaDB version
244+
currentVer := getVersionFromMeta(ctx, meta)
245+
minVer, _ := version.NewVersion("10.1.1")
246+
247+
if currentVer.LessThan(minVer) {
248+
return fmt.Errorf("MAX_STATEMENT_TIME requires MariaDB 10.1.1 or newer (current version: %s)", currentVer.String())
249+
}
250+
251+
return nil
252+
}
253+
177254
func CreateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
178255
db, err := getDatabaseFromMeta(ctx, meta)
179256
if err != nil {
@@ -296,6 +373,35 @@ func CreateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) d
296373
}
297374
}
298375

376+
// Add resource limits if specified
377+
// Note: MySQL 5.6 does NOT support CREATE USER ... WITH for resource limits
378+
// MySQL 5.7.6+ supports both CREATE USER ... WITH and ALTER USER ... WITH
379+
// For MySQL < 5.7.6, we need to use GRANT USAGE after CREATE USER
380+
var resourceLimits []string
381+
if createObj != "AADUSER" {
382+
// MAX_USER_CONNECTIONS - supported on MySQL and MariaDB, but not TiDB
383+
if maxConn, ok := d.GetOk("max_user_connections"); ok {
384+
if err := checkMaxUserConnectionsSupport(ctx, meta); err != nil {
385+
return diag.FromErr(err)
386+
}
387+
resourceLimits = append(resourceLimits, fmt.Sprintf("MAX_USER_CONNECTIONS %d", maxConn.(int)))
388+
}
389+
390+
// MAX_STATEMENT_TIME - MariaDB only
391+
if maxStmt, ok := d.GetOk("max_statement_time"); ok {
392+
if err := checkMaxStatementTimeSupport(ctx, meta); err != nil {
393+
return diag.FromErr(err)
394+
}
395+
resourceLimits = append(resourceLimits, fmt.Sprintf("MAX_STATEMENT_TIME %f", maxStmt.(float64)))
396+
}
397+
398+
// MySQL 5.7.6+ supports CREATE USER ... WITH for resource limits
399+
createUserWithVersion, _ := version.NewVersion("5.7.6")
400+
if len(resourceLimits) > 0 && getVersionFromMeta(ctx, meta).GreaterThanOrEqual(createUserWithVersion) {
401+
stmtSQL += " WITH " + strings.Join(resourceLimits, " ")
402+
}
403+
}
404+
299405
// Log statement with sensitive values redacted
300406
logStmt := stmtSQL
301407
if password != "" {
@@ -311,6 +417,20 @@ func CreateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) d
311417
return diag.Errorf("failed executing SQL: %v", err)
312418
}
313419

420+
// For MySQL < 5.7.6, use GRANT USAGE to set resource limits after CREATE USER
421+
createUserWithVersion, _ := version.NewVersion("5.7.6")
422+
if createObj != "AADUSER" && len(resourceLimits) > 0 && getVersionFromMeta(ctx, meta).LessThan(createUserWithVersion) {
423+
grantStmtSQL := fmt.Sprintf("GRANT USAGE ON *.* TO %s WITH %s",
424+
formatUserIdentifier(user, host),
425+
strings.Join(resourceLimits, " "))
426+
427+
log.Println("[DEBUG] Executing statement:", grantStmtSQL)
428+
_, err = db.ExecContext(ctx, grantStmtSQL)
429+
if err != nil {
430+
return diag.Errorf("failed setting user resource limits: %v", err)
431+
}
432+
}
433+
314434
userId := fmt.Sprintf("%s@%s", user, host)
315435
d.SetId(userId)
316436

@@ -453,9 +573,104 @@ func UpdateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) d
453573
}
454574
}
455575

576+
// Handle resource limits changes (Option B: field removal resets to 0)
577+
// MySQL 5.6: ALTER USER only supports PASSWORD EXPIRE, use GRANT USAGE for resource limits
578+
// MySQL 5.7.6+: ALTER USER supports WITH clause for resource limits
579+
if d.HasChange("max_user_connections") || d.HasChange("max_statement_time") {
580+
var resourceLimits []string
581+
582+
// Handle MAX_USER_CONNECTIONS
583+
if maxConn, ok := d.GetOk("max_user_connections"); ok {
584+
// Field is present in config, validate and set the value
585+
if err := checkMaxUserConnectionsSupport(ctx, meta); err != nil {
586+
return diag.FromErr(err)
587+
}
588+
resourceLimits = append(resourceLimits, fmt.Sprintf("MAX_USER_CONNECTIONS %d", maxConn.(int)))
589+
} else if d.HasChange("max_user_connections") {
590+
// Field was removed from config, reset to 0 (unlimited)
591+
// Only reset if we're not on TiDB (which doesn't support this feature)
592+
isTiDBVal, _, _, err := serverTiDB(db)
593+
if err != nil {
594+
return diag.FromErr(err)
595+
}
596+
if !isTiDBVal {
597+
resourceLimits = append(resourceLimits, "MAX_USER_CONNECTIONS 0")
598+
} else {
599+
return diag.Errorf("cannot reset max_user_connections on TiDB: MAX_USER_CONNECTIONS is not supported on TiDB")
600+
}
601+
}
602+
603+
// Handle MAX_STATEMENT_TIME (MariaDB only)
604+
if maxStmt, ok := d.GetOk("max_statement_time"); ok {
605+
// Field is present in config, validate and set the value
606+
if err := checkMaxStatementTimeSupport(ctx, meta); err != nil {
607+
return diag.FromErr(err)
608+
}
609+
resourceLimits = append(resourceLimits, fmt.Sprintf("MAX_STATEMENT_TIME %f", maxStmt.(float64)))
610+
} else if d.HasChange("max_statement_time") {
611+
// Field was removed from config, reset to 0 (unlimited)
612+
// Only reset if we're on MariaDB (no need to check version, just database type)
613+
isMariaDBVal, err := serverMariaDB(db)
614+
if err != nil {
615+
return diag.FromErr(err)
616+
}
617+
if isMariaDBVal {
618+
resourceLimits = append(resourceLimits, "MAX_STATEMENT_TIME 0")
619+
}
620+
}
621+
622+
if len(resourceLimits) > 0 {
623+
var stmtSQL string
624+
625+
// MySQL versions before 5.7.6 don't support ALTER USER with WITH clause
626+
// Use GRANT USAGE instead for older versions
627+
alterUserVersion, _ := version.NewVersion("5.7.6")
628+
if getVersionFromMeta(ctx, meta).LessThan(alterUserVersion) {
629+
// MySQL 5.6 and earlier: use GRANT USAGE
630+
stmtSQL = fmt.Sprintf("GRANT USAGE ON *.* TO %s WITH %s",
631+
formatUserIdentifier(d.Get("user").(string), d.Get("host").(string)),
632+
strings.Join(resourceLimits, " "))
633+
} else {
634+
// MySQL 5.7.6+: use ALTER USER
635+
stmtSQL = fmt.Sprintf("ALTER USER %s WITH %s",
636+
formatUserIdentifier(d.Get("user").(string), d.Get("host").(string)),
637+
strings.Join(resourceLimits, " "))
638+
}
639+
640+
log.Println("[DEBUG] Executing query:", stmtSQL)
641+
_, err := db.ExecContext(ctx, stmtSQL)
642+
if err != nil {
643+
return diag.Errorf("failed setting user resource limits: %v", err)
644+
}
645+
}
646+
}
647+
456648
return nil
457649
}
458650

651+
// parseWithClauseSetting extracts and sets a resource limit from the WITH clause
652+
func parseWithClauseSetting(d *schema.ResourceData, withClause, fieldName, settingName string, parseAsFloat bool) {
653+
// Only set if the field is currently being managed (Option B behavior)
654+
if _, ok := d.GetOk(fieldName); !ok {
655+
return
656+
}
657+
658+
pattern := fmt.Sprintf(`%s\s+([\d.]+)`, settingName)
659+
re := regexp.MustCompile(pattern)
660+
661+
if match := re.FindStringSubmatch(withClause); len(match) > 1 {
662+
if parseAsFloat {
663+
if value, err := strconv.ParseFloat(match[1], 64); err == nil {
664+
d.Set(fieldName, value)
665+
}
666+
} else {
667+
if value, err := strconv.Atoi(match[1]); err == nil {
668+
d.Set(fieldName, value)
669+
}
670+
}
671+
}
672+
}
673+
459674
func ReadUser(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
460675
db, err := getDatabaseFromMeta(ctx, meta)
461676
if err != nil {
@@ -545,13 +760,36 @@ func ReadUser(ctx context.Context, d *schema.ResourceData, meta interface{}) dia
545760
d.Set("auth_string_hex", "")
546761
}
547762
}
763+
764+
// Parse resource limits from WITH clause if present
765+
// Examples of WITH clause in CREATE USER:
766+
// CREATE USER 'user'@'host' ... WITH MAX_USER_CONNECTIONS 10
767+
// CREATE USER 'user'@'host' ... WITH MAX_STATEMENT_TIME 30.5 (MariaDB only)
768+
// CREATE USER 'user'@'host' ... WITH MAX_USER_CONNECTIONS 5 MAX_STATEMENT_TIME 60.0
769+
withRe := regexp.MustCompile(`WITH\s+(.*)$`)
770+
if withMatch := withRe.FindStringSubmatch(createUserStmt); len(withMatch) > 1 {
771+
withClause := withMatch[1]
772+
773+
parseWithClauseSetting(d, withClause, "max_user_connections", "MAX_USER_CONNECTIONS", false)
774+
parseWithClauseSetting(d, withClause, "max_statement_time", "MAX_STATEMENT_TIME", true)
775+
}
776+
548777
return nil
549778
}
550779

551780
// Try 2 - just whether the user is there.
552781
re2 := regexp.MustCompile("^CREATE USER")
553782
if m := re2.FindStringSubmatch(createUserStmt); m != nil {
554783
// Ok, we have at least something - it's probably in MariaDB.
784+
// Parse resource limits from WITH clause if present (MariaDB format)
785+
withRe := regexp.MustCompile(`WITH\s+(.*)$`)
786+
if withMatch := withRe.FindStringSubmatch(createUserStmt); len(withMatch) > 1 {
787+
withClause := withMatch[1]
788+
789+
parseWithClauseSetting(d, withClause, "max_user_connections", "MAX_USER_CONNECTIONS", false)
790+
parseWithClauseSetting(d, withClause, "max_statement_time", "MAX_STATEMENT_TIME", true)
791+
}
792+
555793
return nil
556794
}
557795
return diag.Errorf("Create user couldn't be parsed - it is %s", createUserStmt)

0 commit comments

Comments
 (0)