package main //test:in-package — the agent is a main package, which has no external test // package to import it from, and these cases drive the unexported mariadbEngine // and its parameter store directly. import ( "context" "errors" "fmt" "os" "os/user" "path/filepath" "slices" "strconv" "strings" "testing" "time" handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds" ) // Stands in for the control plane's own definition of the engine. It mirrors the // production wiring exactly, including that a name the catalog does not carry // counts as static: a rule the fake inverted would hide the failure it exists to // prevent. func testMariaDBRules() controlPlaneRules { applyTypes := map[string]string{ "innodb_buffer_pool_size": handlers_rds.ApplyTypeStatic, "innodb_log_file_size": handlers_rds.ApplyTypeStatic, "max_connections": handlers_rds.ApplyTypeDynamic, "long_query_time": handlers_rds.ApplyTypeDynamic, "time_zone": handlers_rds.ApplyTypeDynamic, "innodb_adaptive_hash_index": handlers_rds.ApplyTypeDynamic, "log_output": handlers_rds.ApplyTypeDynamic, "require_secure_transport": handlers_rds.ApplyTypeDynamic, } dataTypes := map[string]string{ "innodb_buffer_pool_size": handlers_rds.ParamTypeInteger, "innodb_log_file_size": handlers_rds.ParamTypeInteger, "max_connections": handlers_rds.ParamTypeInteger, "long_query_time": handlers_rds.ParamTypeReal, "time_zone": handlers_rds.ParamTypeString, "innodb_adaptive_hash_index": handlers_rds.ParamTypeBoolean, "log_output": handlers_rds.ParamTypeEnum, "require_secure_transport": handlers_rds.ParamTypeBoolean, } reserved := []string{"root", "mysql", "mariadb.sys", "rdsadmin", "public"} return controlPlaneRules{ validateUsername: func(username string) error { lower := strings.ToLower(username) if slices.Contains(reserved, lower) || strings.HasPrefix(lower, "mysql.") || strings.HasPrefix(lower, "mariadb.") { return fmt.Errorf("MasterUsername %q is reserved by mariadb", username) } for _, r := range username { if !isTestIdentifierRune(r) { return fmt.Errorf("MasterUsername may contain only letters, digits and underscores") } } return nil }, isStatic: func(name string) bool { applyType, ok := applyTypes[name] return !ok || applyType == handlers_rds.ApplyTypeStatic }, catalogName: func(optionFileName string) string { if optionFileName == "default_time_zone" { return "time_zone" } return optionFileName }, dataType: func(name string) string { return dataTypes[name] }, tlsEnforcementParameter: "require_secure_transport", } } func isTestIdentifierRune(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' } func newTestMariaDBEngine(t *testing.T, run commandRunner) *mariadbEngine { t.Helper() return newScriptedTestMariaDBEngine(t, withMariaDBReadBacks(run)) } // Answers the read-backs an apply runs against a live server, and does not // record the calls: an apply against a set that says nothing about TLS still // asks both, and a case counting the statements it issued is about the set // rather than the guard. func withMariaDBReadBacks(run commandRunner) commandRunner { return func(ctx context.Context, c command) (string, error) { switch { case strings.Contains(c.Stdin, "have_ssl"): return "YES\n", nil // Already enforcing, which is what rds-init leaves behind: an apply that // does not move the posture issues no statement for it. case strings.Contains(c.Stdin, "@@global.require_secure_transport"): return "1\n", nil } return run(ctx, c) } } // The same engine with the read-back left to the runner, for the cases that are // about what the engine does with that answer rather than about the rest of an // apply. func newScriptedTestMariaDBEngine(t *testing.T, run commandRunner) *mariadbEngine { t.Helper() cfg := testLoadConfig(t, engineMariaDB) cfg.DataMount = t.TempDir() if err := os.MkdirAll(filepath.Join(cfg.DataMount, "conf.d"), 0o750); err != nil { t.Fatalf("create conf.d: %v", err) } // The guest's mysql user does not exist here, and the chown of the installed // parameter file has to resolve to something real. current, err := user.Current() if err != nil { t.Fatalf("resolve the current user: %v", err) } cfg.EngineUser = current.Username state := engineServing return newMariaDBEngine(cfg, testMariaDBRules(), run, nil, drivenProbe(&state)) } // The wiring the guest takes its classification from, checked against a catalog // that is registered: what a spec says about a parameter is what the guest must // see, and an unlisted name must read as static rather than as dynamic. func TestControlPlaneRulesFrom_TakesTheClassificationFromTheCatalog(t *testing.T) { rules := controlPlaneRulesFrom(postgresEngineMeta) for name, want := range map[string]bool{"shared_buffers": true, "work_mem": false} { spec, ok := postgresEngineMeta.LookupParameter(name) if !ok { t.Fatalf("the catalog no longer carries %s", name) } if (spec.ApplyType == handlers_rds.ApplyTypeStatic) != want { t.Fatalf("%s is %s in the catalog, so this test is asserting the wrong way round", name, spec.ApplyType) } if got := rules.isStatic(name); got != want { t.Errorf("isStatic(%s) = %v, want %v", name, got, want) } } if !rules.isStatic("not_a_parameter_any_catalog_carries") { t.Error("a name the catalog does not carry read as dynamic, so it would be issued as a live SET GLOBAL") } if rules.validateUsername("postgres") == nil { t.Error("the username rule does not refuse a reserved role") } } // The client has no parameter quoting of its own, so the password is built into // the statement here. It must reach the process on stdin and nowhere else. func TestMariaDBEngine_SetPasswordKeepsTheSecretOutOfArgvAndEnv(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil { t.Fatalf("SetPassword: %v", err) } if len(runner.calls) != 1 { t.Fatalf("ran %d commands, want 1", len(runner.calls)) } call := runner.calls[0] for _, arg := range call.Args { if strings.Contains(arg, "n3w-pw") { t.Fatalf("argv %v carries the password", call.Args) } } for _, env := range call.Env { if strings.Contains(env, "n3w-pw") { t.Fatalf("env %v carries the password, which the client would not read anyway", call.Env) } } if !strings.Contains(call.Stdin, "ALTER USER 'mulgamaster'@'%' IDENTIFIED BY 'n3w-pw';") { t.Errorf("SQL %q does not rotate the master on the wildcard host", call.Stdin) } } // The serving engine offers TLS whose cert names the endpoint rather than this // socket, so a client that verifies is refused during the handshake and every // statement the agent issues fails. The probe and the statement client are // separate argv sites, and only one of them carrying the flag is the bug. func TestMariaDBEngine_EveryLocalClientDeclinesTLS(t *testing.T) { engine := newTestMariaDBEngine(t, (&recordingRunner{}).run) cfg := mariadbTestProbeConfig(t) sites := map[string][]string{ "statement client": engine.clientArgs(), "probe": mariadbSocketConnectArgs(mariadbSocketPath(cfg)), } for name, args := range sites { t.Run(name, func(t *testing.T) { if !slices.Contains(args, "--skip-ssl") { t.Errorf("%s args = %v, want TLS declined on the unix socket", name, args) } if !slices.Contains(args, "--no-defaults") { t.Errorf("%s args = %v, want no option file able to move the connection", name, args) } }) } } // The shared constructor hands each caller its own slice, so one appending its // own flags cannot reach into what another already holds. func TestMariaDBSocketConnectArgsDoNotAlias(t *testing.T) { first := mariadbSocketConnectArgs("/run/mysqld/mysqld.sock") appended := append(mariadbSocketConnectArgs("/run/mysqld/mysqld.sock"), "--connect-timeout=3") if slices.Contains(first, "--connect-timeout=3") { t.Errorf("args = %v, want the second caller's flag kept out of the first's slice", first) } if len(appended) != len(first)+1 { t.Errorf("appended = %v, want exactly one flag more than %v", appended, first) } } // ValidateMasterUserPassword permits both of these, and the client offers no // equivalent of psql's parameter quoting, so the escaping here is what keeps a // password from ending the literal it is inside. func TestMariaDBEngine_SetPasswordEscapesQuotesAndBackslashes(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := engine.SetPassword(context.Background(), "mulgamaster", `a'b\c`); err != nil { t.Fatalf("SetPassword: %v", err) } sql := runner.calls[0].Stdin if !strings.Contains(sql, `IDENTIFIED BY 'a''b\\c';`) { t.Errorf("SQL %q does not escape both the quote and the backslash", sql) } // The escaping assumes a mode where backslash escapes are on, so the statement // cannot inherit whatever sql_mode the customer's parameter group set. if !strings.Contains(sql, "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';") { t.Errorf("SQL %q does not pin the mode its escaping assumes", sql) } } // The password is interpolated into the statement, so a parameter group that // turned the general or slow log on would copy it into a log file every snapshot // then carries. Both are silenced for this session only: a rotation must not // leave the customer's own logging switched off behind it. func TestMariaDBEngine_SetPasswordSilencesStatementLoggingForItsSessionOnly(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil { t.Fatalf("SetPassword: %v", err) } sql := runner.calls[0].Stdin alter := strings.Index(sql, "ALTER USER") if alter < 0 { t.Fatalf("SQL %q does not alter the user", sql) } for _, guard := range []string{"SET SESSION sql_log_off = 1;", "SET SESSION slow_query_log = 0;"} { at := strings.Index(sql, guard) if at < 0 { t.Errorf("SQL %q is missing %q", sql, guard) continue } if at > alter { t.Errorf("SQL %q sets %q only after the statement has already been logged", sql, guard) } } if strings.Contains(sql, "SET GLOBAL general_log") || strings.Contains(sql, "SET GLOBAL slow_query_log") { t.Errorf("SQL %q turns logging off globally, which outlives the rotation", sql) } } // The client echoes the statement it failed on, which carries the escaped form // of the password rather than the raw one — so redacting only the raw one would // leak exactly the passwords that needed escaping. func TestMariaDBEngine_SetPasswordRedactsBothFormsOfTheSecret(t *testing.T) { secret := `a'b\c` runner := &recordingRunner{err: errors.New(`ERROR 1064 near "IDENTIFIED BY 'a''b\\c'"`)} engine := newTestMariaDBEngine(t, runner.run) err := engine.SetPassword(context.Background(), "mulgamaster", secret) if err == nil { t.Fatal("SetPassword succeeded against a failing client") } if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), sqlLiteral(secret)) { t.Errorf("error %q leaks the password", err) } if !strings.Contains(err.Error(), "[REDACTED]") { t.Errorf("error %q does not mark the redaction", err) } } // The apply runs as the server's own superuser over the socket, and the name is // interpolated into the statement rather than quoted by the client, so both // halves of the rule are refused before any SQL is built. func TestMariaDBEngine_SetPasswordRefusesNamesTheEngineWillNotTake(t *testing.T) { for _, username := range []string{"root", "mysql", "mariadb.sys", "rdsadmin", "PUBLIC", "mysql.infoschema", `ma'ster`} { t.Run(username, func(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := engine.SetPassword(context.Background(), username, "n3w-pw"); err == nil { t.Fatalf("SetPassword accepted %q", username) } if len(runner.calls) != 0 { t.Errorf("ran %d commands for a refused name, want 0", len(runner.calls)) } }) } } func TestMariaDBEngine_SetPasswordRejectsAnIncompleteCommand(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := engine.SetPassword(context.Background(), "mulgamaster", ""); err == nil { t.Error("SetPassword accepted an empty password") } if len(runner.calls) != 0 { t.Errorf("ran %d commands for an incomplete request, want 0", len(runner.calls)) } } // A running server re-reads no configuration file, so the dynamic half has to // reach it as SET GLOBAL. The static half is installed and waits for a restart, // which is what comes back as pending. func TestMariaDBEngine_ApplyParametersSetsOnlyTheDynamicHalfLive(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) pending, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{ {Name: "max_connections", Value: "200"}, {Name: "innodb_buffer_pool_size", Value: "1073741824"}, }) if err != nil { t.Fatalf("ApplyParameters: %v", err) } installed, err := os.ReadFile(engine.params.installedPath()) if err != nil { t.Fatalf("read the installed parameters: %v", err) } if !strings.HasPrefix(string(installed), "[mysqld]\n") { t.Errorf("installed parameters = %q, want the group header rds-init writes", installed) } for _, want := range []string{"max_connections = '200'", "innodb_buffer_pool_size = '1073741824'"} { if !strings.Contains(string(installed), want) { t.Errorf("installed parameters = %q, want %q", installed, want) } } if len(runner.calls) != 1 { t.Fatalf("ran %d commands, want the one live apply", len(runner.calls)) } sql := runner.calls[0].Stdin if !strings.Contains(sql, "SET GLOBAL max_connections = 200;") { t.Errorf("SQL %q does not set the dynamic value live", sql) } if strings.Contains(sql, "innodb_buffer_pool_size") { t.Errorf("SQL %q sets a static value live, which the server would refuse", sql) } if !slices.Equal(pending, []string{"innodb_buffer_pool_size"}) { t.Errorf("pending = %v, want the static setting the engine has not adopted", pending) } } // MariaDB refuses a quoted literal for a numeric or boolean system variable, so // a single quoting rule for the whole set fails on the first number in it — // which the resolved set always carries, whatever the customer changed. func TestMariaDBEngine_ApplyParametersRendersEachValueByItsCatalogType(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{ {Name: "max_connections", Value: "200"}, {Name: "long_query_time", Value: "2.5"}, {Name: "innodb_adaptive_hash_index", Value: "on"}, {Name: "log_output", Value: "FILE"}, }); err != nil { t.Fatalf("ApplyParameters: %v", err) } if len(runner.calls) != 1 { t.Fatalf("ran %d commands, want the one live apply", len(runner.calls)) } sql := runner.calls[0].Stdin for _, want := range []string{ "SET GLOBAL max_connections = 200;", "SET GLOBAL long_query_time = 2.5;", "SET GLOBAL innodb_adaptive_hash_index = ON;", "SET GLOBAL log_output = 'FILE';", } { if !strings.Contains(sql, want) { t.Errorf("SQL %q does not contain %q", sql, want) } } } // A numeric that is not a number never reaches the server: an unquoted // right-hand side is only safe to interpolate because of this check. func TestMariaDBEngine_ApplyParametersRefusesANonNumericNumber(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{{Name: "max_connections", Value: "200; DROP DATABASE orders"}}) if err == nil { t.Fatal("ApplyParameters accepted a non-numeric value for an integer parameter") } if len(runner.calls) != 0 { t.Errorf("ran %d commands for a refused value, want 0", len(runner.calls)) } } // A set with nothing dynamic in it must not open a connection at all: the // installed file is the whole of the change. func TestMariaDBEngine_ApplyParametersRunsNoStatementForAStaticOnlySet(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{{Name: "innodb_log_file_size", Value: "268435456"}}); err != nil { t.Fatalf("ApplyParameters: %v", err) } if len(runner.calls) != 0 { t.Errorf("ran %d commands for a static-only set, want 0", len(runner.calls)) } } // A value the server refuses leaves the installed file back where it was, so it // does not sit on the data volume turning the next restart into a boot loop. func TestMariaDBEngine_ApplyParametersRollsBackAValueTheServerRefuses(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{{Name: "max_connections", Value: "200"}}); err != nil { t.Fatalf("first ApplyParameters: %v", err) } runner.err = errors.New("ERROR 1231 (42000): Variable 'max_connections' can't be set to the value of '0'") if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{{Name: "max_connections", Value: "0"}}); err == nil { t.Fatal("ApplyParameters succeeded against a value the server refused") } installed, err := os.ReadFile(engine.params.installedPath()) if err != nil { t.Fatalf("read the installed parameters: %v", err) } if !strings.Contains(string(installed), "max_connections = '200'") { t.Errorf("installed parameters = %q, want the previous accepted set restored", installed) } } // Answers the two TLS read-backs from fields the case sets, so what the engine // does with each answer is what is under test. liveEnforcement is the value the // server is already on. // // failOn fails one statement and then stops: with liveEnforcementAfter it is the // client that failed without saying whether the server ran what it was sent. type mariadbTLSReadBackRunner struct { recordingRunner haveSSL string liveEnforcement string failOn string failErr error liveEnforcementAfter string } func (r *mariadbTLSReadBackRunner) run(ctx context.Context, c command) (string, error) { switch { case strings.Contains(c.Stdin, "have_ssl"): r.calls = append(r.calls, c) return r.haveSSL + "\n", nil case strings.Contains(c.Stdin, "@@global.require_secure_transport"): r.calls = append(r.calls, c) if r.liveEnforcement == "" { return "0\n", nil } return r.liveEnforcement + "\n", nil case r.failOn != "" && strings.Contains(c.Stdin, r.failOn): r.calls = append(r.calls, c) r.failOn, r.liveEnforcement = "", r.liveEnforcementAfter return "", r.failErr } return r.recordingRunner.run(ctx, c) } func requireSecureTransport(value string) []handlers_rds.Parameter { return []handlers_rds.Parameter{ {Name: "max_connections", Value: "200"}, {Name: "require_secure_transport", Value: value}, } } // Unlike PostgreSQL's, this is a real global system variable: the value in the // file is the enforcement, and a running server takes it as SET GLOBAL. func TestMariaDBEngine_ApplyParametersEnforcesTLSLive(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "YES"} engine := newScriptedTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(context.Background(), requireSecureTransport("1")); err != nil { t.Fatalf("ApplyParameters: %v", err) } installed, err := os.ReadFile(engine.params.installedPath()) if err != nil { t.Fatalf("read the installed parameters: %v", err) } if !strings.Contains(string(installed), "require_secure_transport = '1'") { t.Errorf("installed parameters = %q, want the value the next start reads", installed) } sql := runner.calls[len(runner.calls)-1].Stdin if !strings.Contains(sql, "SET GLOBAL require_secure_transport = ON;") { t.Errorf("SQL %q does not require TLS of clients without a restart", sql) } } // Without this the apply would reject every client of an engine started with no // certificate, and report the parameter applied. func TestMariaDBEngine_ApplyParametersRefusesToEnforceWithoutTLS(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "DISABLED"} engine := newScriptedTestMariaDBEngine(t, runner.run) _, err := engine.ApplyParameters(context.Background(), requireSecureTransport("1")) if err == nil || !strings.Contains(err.Error(), "serving without TLS") { t.Fatalf("ApplyParameters error = %v, want a refusal naming the engine's own TLS state", err) } for _, c := range runner.calls { if strings.Contains(c.Stdin, "SET GLOBAL") { t.Errorf("the set reached the server anyway: %q", c.Stdin) } } if _, statErr := os.Stat(engine.params.installedPath()); !os.IsNotExist(statErr) { t.Errorf("the refused set is still installed (stat err = %v)", statErr) } } // An absent key reads as enforce here, as it does on the PostgreSQL side. The // server's own default for it is off, so this decides only whether the engine // may be asked to enforce — never whether it does. func TestMariaDBEngine_ApplyParametersReadsAnAbsentEnforcementKeyAsOn(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "DISABLED"} engine := newScriptedTestMariaDBEngine(t, runner.run) _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{{Name: "max_connections", Value: "200"}}) if err == nil || !strings.Contains(err.Error(), "serving without TLS") { t.Fatalf("ApplyParameters error = %v, want a set predating the parameter to read as enforcing", err) } } // A set that predates the parameter reads as enforce, and the server's own // default for it is off — so the apply has to issue the statement rather than // leave the server accepting plaintext under an API reporting enforcement. func TestMariaDBEngine_ApplyParametersEnforcesOnAnAbsentKey(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "YES", liveEnforcement: "0"} engine := newScriptedTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{{Name: "max_connections", Value: "200"}}); err != nil { t.Fatalf("ApplyParameters: %v", err) } sql := runner.calls[len(runner.calls)-1].Stdin if !strings.Contains(sql, "SET GLOBAL require_secure_transport = ON;") { t.Errorf("SQL %q left a server that predates the parameter accepting plaintext", sql) } } // The batch of SET GLOBAL statements is not a transaction, so enforcement is // issued after it rather than sorted into it: a refusal partway through must not // leave the server's TLS posture already moved. func TestMariaDBEngine_ApplyParametersLeavesTLSAloneWhenTheBatchFails(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "YES", liveEnforcement: "1"} engine := newScriptedTestMariaDBEngine(t, runner.run) runner.err = errors.New("ERROR 1231 (42000): Variable 'max_connections' can't be set to the value of '0'") if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{ {Name: "max_connections", Value: "0"}, {Name: "require_secure_transport", Value: "0"}, }); err == nil { t.Fatal("ApplyParameters succeeded against a value the server refused") } for _, c := range runner.calls { if strings.Contains(c.Stdin, "SET GLOBAL require_secure_transport") { t.Errorf("a failed batch moved the TLS posture anyway: %q", c.Stdin) } } } // The client can fail without saying whether the server ran the statement, so // the posture is read back rather than assumed unchanged. Plaintext nobody asked // for is the one outcome that must not survive a failed apply. func TestMariaDBEngine_ApplyParametersRestoresTLSAfterAnAmbiguousFailure(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "YES", liveEnforcement: "1"} engine := newScriptedTestMariaDBEngine(t, runner.run) // The statement ran and the client still reported a failure, which is what // leaves the live value where the caller cannot infer it. runner.failOn = "SET GLOBAL require_secure_transport" runner.failErr = errors.New("ERROR 2013 (HY000): Lost connection to server during query") runner.liveEnforcementAfter = "0" if _, err := engine.ApplyParameters(context.Background(), requireSecureTransport("0")); err == nil { t.Fatal("ApplyParameters succeeded against a client that failed") } restored := 0 for _, c := range runner.calls { if strings.Contains(c.Stdin, "SET GLOBAL require_secure_transport = ON;") { restored++ } } if restored != 1 { t.Errorf("issued %d statements putting enforcement back, want 1", restored) } } // The resolver canonicalises every boolean, so a value that is neither means the // file was written by something other than the platform. Reading an unparsable // security setting as off is the one choice not open here. func TestMariaDBEngine_ApplyParametersRefusesAnUnparsableEnforcementValue(t *testing.T) { runner := &mariadbTLSReadBackRunner{haveSSL: "YES"} engine := newScriptedTestMariaDBEngine(t, runner.run) _, err := engine.ApplyParameters(context.Background(), requireSecureTransport("yes")) if err == nil || !strings.Contains(err.Error(), "neither 1 nor 0") { t.Fatalf("ApplyParameters error = %v, want a refusal naming the unreadable value", err) } if len(runner.calls) != 0 { t.Errorf("ran %d commands against a set that could not be read, want 0", len(runner.calls)) } if _, statErr := os.Stat(engine.params.installedPath()); !os.IsNotExist(statErr) { t.Errorf("the refused set is still installed (stat err = %v)", statErr) } } // The whole of the pending-restart answer, since MariaDB has none of its own: // the installed drop-in against the one the server actually started on, over the // static keys only. func TestMariaDBEngine_PendingRestartComparesTheInstalledSetAgainstTheServingCopy(t *testing.T) { engine := newTestMariaDBEngine(t, (&recordingRunner{}).run) write := func(path, body string) { t.Helper() if err := os.WriteFile(path, []byte("[mysqld]\n"+body), 0o600); err != nil { t.Fatalf("write %s: %v", path, err) } } pending := func() []string { t.Helper() names, err := engine.pendingRestartParameters(t.Context()) if err != nil { t.Fatalf("pendingRestartParameters: %v", err) } return names } // A fresh boot: rds-init wrote the two together, so nothing is waiting. write(engine.params.installedPath(), "innodb_buffer_pool_size = '1073741824'\nmax_connections = '100'\n") write(engine.params.servingPath(), "innodb_buffer_pool_size = '1073741824'\nmax_connections = '100'\n") if names := pending(); len(names) != 0 { t.Errorf("pending = %v, want nothing on a set the engine started on", names) } // A dynamic value the server took live is not pending: it was adopted without // a restart, which is exactly what the serving copy not moving records. write(engine.params.installedPath(), "innodb_buffer_pool_size = '1073741824'\nmax_connections = '200'\n") if names := pending(); len(names) != 0 { t.Errorf("pending = %v, want nothing for a value adopted live", names) } // A static value is. write(engine.params.installedPath(), "innodb_buffer_pool_size = '2147483648'\nmax_connections = '200'\n") if names := pending(); !slices.Equal(names, []string{"innodb_buffer_pool_size"}) { t.Errorf("pending = %v, want the static setting", names) } // So is a static setting the group stopped naming, which reverts to its // default at the next start. write(engine.params.installedPath(), "max_connections = '200'\n") if names := pending(); !slices.Equal(names, []string{"innodb_buffer_pool_size"}) { t.Errorf("pending = %v, want the withdrawn static setting", names) } // MariaDB reads - and _ as the same character, so one setting spelled two ways // is not two settings. write(engine.params.installedPath(), "innodb-buffer-pool-size = '1073741824'\nmax_connections = '200'\n") if names := pending(); len(names) != 0 { t.Errorf("pending = %v, want the two spellings of one setting to compare equal", names) } } // rds-init writes the serving copy beside the installed one on every boot, so a // set with no copy beside it is one the engine has not started on. Promoting it // would point the rollback at a configuration that has never served. func TestMariaDBEngine_PendingRestartTreatsAMissingServingCopyAsUnadopted(t *testing.T) { engine := newTestMariaDBEngine(t, (&recordingRunner{}).run) if err := os.WriteFile(engine.params.installedPath(), []byte("[mysqld]\ninnodb_buffer_pool_size = '1073741824'\nmax_connections = '100'\n"), 0o600); err != nil { t.Fatalf("write installed parameters: %v", err) } names, err := engine.pendingRestartParameters(t.Context()) if err != nil { t.Fatalf("pendingRestartParameters: %v", err) } if !slices.Equal(names, []string{"innodb_buffer_pool_size"}) { t.Errorf("pending = %v, want the static half of a set the engine never started on", names) } } // The restart the agent performs is the only thing that moves the serving copy, // which is what makes a fresh start clear whatever was pending. func TestMariaDBEngine_RestartRefreshesTheServingCopy(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) installed := []byte("[mysqld]\ninnodb_buffer_pool_size = '2147483648'\n") if err := os.WriteFile(engine.params.installedPath(), installed, 0o600); err != nil { t.Fatalf("write installed parameters: %v", err) } if err := os.WriteFile(engine.params.servingPath(), []byte("[mysqld]\ninnodb_buffer_pool_size = '1073741824'\n"), 0o600); err != nil { t.Fatalf("write serving parameters: %v", err) } if err := engine.Restart(context.Background()); err != nil { t.Fatalf("Restart: %v", err) } serving, err := os.ReadFile(engine.params.servingPath()) if err != nil { t.Fatalf("read the serving copy: %v", err) } if string(serving) != string(installed) { t.Errorf("serving copy = %q, want the set the engine was just started on", serving) } names, err := engine.pendingRestartParameters(t.Context()) if err != nil { t.Fatalf("pendingRestartParameters: %v", err) } if len(names) != 0 { t.Errorf("pending = %v, want a restart to have cleared it", names) } // The copy the comparison reads must never be parsed as a second set of // settings by the engine's own include glob. if strings.HasSuffix(engine.params.servingPath(), ".cnf") { t.Errorf("the serving copy at %s would be included by the engine", engine.params.servingPath()) } } // The rollback target advances only once a restart has proved the installed // static values can serve, not when a dynamic apply merely set them live. func TestMariaDBEngine_LastKnownGoodWaitsForARestartToAdoptAStaticSet(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{{Name: "innodb_buffer_pool_size", Value: "1073741824"}}); err != nil { t.Fatalf("ApplyParameters: %v", err) } if err := engine.RecordServingParameters(t.Context()); err != nil { t.Fatalf("RecordServingParameters: %v", err) } if _, err := os.Stat(engine.params.lastGoodPath()); !os.IsNotExist(err) { t.Errorf("a set the engine has not restarted onto became the rollback target (stat err = %v)", err) } if err := engine.Restart(t.Context()); err != nil { t.Fatalf("Restart: %v", err) } if err := engine.RecordServingParameters(t.Context()); err != nil { t.Fatalf("RecordServingParameters after the restart: %v", err) } lastGood, err := os.ReadFile(engine.params.lastGoodPath()) if err != nil { t.Fatalf("read the last known good parameters: %v", err) } if !strings.Contains(string(lastGood), "innodb_buffer_pool_size = '1073741824'") { t.Errorf("last known good = %q, want the set the engine restarted onto", lastGood) } } // A dynamic apply is adopted without a restart, so it may advance the rollback // target on the next healthy beat rather than waiting for one. func TestMariaDBEngine_LastKnownGoodAdvancesOnADynamicApply(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := os.WriteFile(engine.params.servingPath(), []byte("[mysqld]\n"), 0o600); err != nil { t.Fatalf("write serving parameters: %v", err) } if _, err := engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{{Name: "max_connections", Value: "200"}}); err != nil { t.Fatalf("ApplyParameters: %v", err) } if err := engine.RecordServingParameters(t.Context()); err != nil { t.Fatalf("RecordServingParameters: %v", err) } lastGood, err := os.ReadFile(engine.params.lastGoodPath()) if err != nil { t.Fatalf("read the last known good parameters: %v", err) } if !strings.Contains(string(lastGood), "max_connections = '200'") { t.Errorf("last known good = %q, want the set the engine is already running", lastGood) } } // A probe that reports what the test hands it, in place of whichever binaries // the engine's own state function would have run. func mariadbTestProbeConfig(t *testing.T) config { t.Helper() cfg := testLoadConfig(t, engineMariaDB) cfg.EnginePidFile = filepath.Join(t.TempDir(), "mariadb.pid") return cfg } func writePid(t *testing.T, path string, pid int) { t.Helper() if err := os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil { t.Fatalf("write pidfile: %v", err) } } // The three stages, and what each one separates. During InnoDB crash recovery // mariadbd opens neither its socket nor its port, so nothing the client can ask // distinguishes a server replaying its redo log from no server at all. func TestMariaDBProbe_SeparatesRecoveringFromAbsent(t *testing.T) { tests := []struct { name string writePid bool alive bool pingCode int pingStderr string pingErr error execCode int want engineState }{ {name: "no pidfile at all", want: engineAbsent}, {name: "pidfile names a dead process", writePid: true, want: engineAbsent}, {name: "process alive but socket silent", writePid: true, alive: true, pingCode: 1, want: engineRecovering}, {name: "process alive but probe cannot run", writePid: true, alive: true, pingErr: errors.New("no such file"), want: engineRecovering}, {name: "answers ping but executes nothing", writePid: true, alive: true, execCode: 1, want: engineRecovering}, {name: "serving", writePid: true, alive: true, want: engineServing}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := mariadbTestProbeConfig(t) if tt.writePid { writePid(t, cfg.EnginePidFile, 4242) } run := func(_ context.Context, name string, args ...string) (int, string, error) { if strings.HasSuffix(name, mariadbAdminBinary) { return tt.pingCode, tt.pingStderr, tt.pingErr } if !slices.Contains(args, "--execute=SELECT 1") { t.Errorf("the second stage ran %s %v, want a statement the server has to execute", name, args) } return tt.execCode, "", nil } stateFn := mariadbProbeState(cfg, run, func(int) bool { return tt.alive }) got, message := stateFn(context.Background(), int64(cfg.EnginePort)) if got != tt.want { t.Errorf("state = %v, want %v (%s)", got, tt.want, message) } if got != engineServing && message == "" { t.Error("a non-serving result carried no message explaining it") } }) } } // The failure the guard exists for still behaves correctly: a static value // mariadbd will not accept makes it exit during startup, so the pidfile names a // process that is gone and the deadline runs. func TestMariaDBProbe_BoundsAStalledClient(t *testing.T) { cfg := mariadbTestProbeConfig(t) cfg.EngineProbeTimeout = 20 * time.Millisecond writePid(t, cfg.EnginePidFile, 4242) started := time.Now() state, message := mariadbProbeState(cfg, func(ctx context.Context, _ string, args ...string) (int, string, error) { if !slices.Contains(args, "--connect-timeout=3") { t.Errorf("probe args = %v, want a client connection timeout", args) } // The platform drop-in offers TLS the serving cert cannot prove for a // local socket, so a verifying client would be refused every time. if !slices.Contains(args, "--skip-ssl") { t.Errorf("probe args = %v, want TLS declined on the unix socket", args) } <-ctx.Done() return -1, "", ctx.Err() }, func(int) bool { return true })(t.Context(), int64(cfg.EnginePort)) if state != engineRecovering { t.Errorf("state = %v, want recovering after a live engine's probe timed out", state) } if !strings.Contains(message, context.DeadlineExceeded.Error()) { t.Errorf("message = %q, want the probe deadline", message) } if elapsed := time.Since(started); elapsed > time.Second { t.Errorf("probe took %v, want the configured deadline to bound it", elapsed) } } func TestMariaDBProbe_MapsToTheHealthTheControlPlaneReads(t *testing.T) { cfg := mariadbTestProbeConfig(t) writePid(t, cfg.EnginePidFile, 4242) alive := true serving := true probe := newEngineProbe(cfg.EnginePort, mariadbProbeState(cfg, func(context.Context, string, ...string) (int, string, error) { if serving { return 0, "", nil } return 1, "", nil }, func(int) bool { return alive })) if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthHealthy { t.Fatalf("health = %q, want healthy", got) } serving = false if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthStarting { t.Errorf("health while recovering = %q, want starting", got) } alive = false if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthUnhealthy { t.Errorf("health after the engine went away = %q, want unhealthy", got) } } // An instance killed hard mid-write can spend minutes replaying its redo log. If // that read as absent, the guard would roll the parameter file back and restart // the server mid-recovery — a rollback that exists to break a boot loop would // create one, on an instance whose parameters were never at fault. func TestMariaDBProbe_LiveButUnreachableEngineResetsTheRollbackDeadline(t *testing.T) { cfg := mariadbTestProbeConfig(t) writePid(t, cfg.EnginePidFile, 4242) probe := newEngineProbe(cfg.EnginePort, mariadbProbeState(cfg, func(context.Context, string, ...string) (int, string, error) { return 1, "", nil }, func(int) bool { return true })) recovery := &fakeRecovery{restored: true} guard := newTestGuard(recovery, probe) // Bounded so the deadline-resetting loop cannot run forever if it stops // resetting; the assertion is that the rollback was never reached. ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) defer cancel() guard.Run(ctx) if recovery.restarts != 0 { t.Errorf("restarts = %d, want none while the engine is still recovering", recovery.restarts) } } // MariaDB releases a BACKUP STAGE with the connection that took it, so the whole // sequence has to run on one held session — which is also what bounds a control // plane that dies mid-snapshot. func TestMariaDBEngine_QuiesceTakesTheBackupStagesOnAHeldSession(t *testing.T) { session := &fakeSession{} engine := newTestMariaDBEngine(t, (&recordingRunner{}).run) var started []command engine.startSess = func(_ context.Context, c command) (engineSession, error) { started = append(started, c) return session, nil } if err := engine.Quiesce(context.Background(), "orders-db-pre-upgrade", time.Minute); err != nil { t.Fatalf("Quiesce: %v", err) } if len(started) != 1 { t.Fatalf("started %d sessions, want 1", len(started)) } if !slices.Contains(started[0].Args, "--unbuffered") { t.Errorf("session argv %v does not flush after each statement, so the sentinel could sit in a pipe buffer", started[0].Args) } statements := session.statements() want := []string{ "SET SESSION lock_wait_timeout = 20;", "BACKUP STAGE START;", "BACKUP STAGE FLUSH;", "BACKUP STAGE BLOCK_DDL;", "BACKUP STAGE BLOCK_COMMIT;", } if len(statements) != len(want) { t.Fatalf("statements = %q, want the bounded stage sequence %q", statements, want) } for i, sql := range want { if strings.TrimSpace(statements[i]) != sql { t.Errorf("statement %d = %q, want %q", i, statements[i], sql) } } // FLUSH TABLES WITH READ LOCK would make the whole database read-only for the // length of the hold rather than blocking commits. if strings.Contains(strings.Join(statements, " "), "FLUSH TABLES") { t.Error("the quiesce took a read lock over the whole database") } if session.closes() != 0 { t.Error("the session was closed while the backup was meant to be held") } } // The stage bound has to leave the control plane's own quiesce timeout room for // the whole sequence, or a stage abandoned mid-wait leaves its lock request // queued in front of live traffic. func TestMariaDBEngine_QuiesceLockWaitFitsInsideTheControlPlaneTimeout(t *testing.T) { const stages = 4 if stages*mariadbQuiesceLockWait >= 2*time.Minute { t.Errorf("%d stages of %s can outlast the control plane's quiesce timeout", stages, mariadbQuiesceLockWait) } } func TestMariaDBEngine_UnquiesceEndsTheBackupAndTheSession(t *testing.T) { session := &fakeSession{} engine := newTestMariaDBEngine(t, (&recordingRunner{}).run) engine.startSess = func(context.Context, command) (engineSession, error) { return session, nil } if err := engine.Quiesce(context.Background(), "orders-db-pre-upgrade", time.Minute); err != nil { t.Fatalf("Quiesce: %v", err) } if err := engine.Unquiesce(context.Background()); err != nil { t.Fatalf("Unquiesce: %v", err) } statements := session.statements() if last := strings.TrimSpace(statements[len(statements)-1]); last != "BACKUP STAGE END;" { t.Errorf("last statement = %q, want the stage released", last) } if session.closes() != 1 { t.Errorf("closed %d times, want the session ended exactly once", session.closes()) } } // The two halves of a time_zone apply use different names: SET GLOBAL takes the // customer's, the option file the server's startup spelling. Getting either // backwards is a statement the server refuses or a boot it refuses. func TestMariaDBEngine_TimeZoneAppliesLiveByItsCustomerFacingName(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if _, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{ {Name: "time_zone", Value: "+10:00"}, }); err != nil { t.Fatalf("ApplyParameters: %v", err) } if len(runner.calls) != 1 { t.Fatalf("ran %d commands, want the one live apply", len(runner.calls)) } sql := runner.calls[0].Stdin if !strings.Contains(sql, "SET GLOBAL time_zone = '+10:00';") { t.Errorf("SQL %q does not set time_zone under the name MariaDB accepts for SET", sql) } if strings.Contains(sql, "default_time_zone") { t.Errorf("SQL %q names the startup spelling, which is not a settable variable", sql) } installed, err := os.ReadFile(engine.params.installedPath()) if err != nil { t.Fatalf("read the installed parameters: %v", err) } if !strings.Contains(string(installed), "default_time_zone = '+10:00'") { t.Errorf("installed parameters = %q, want the startup spelling", installed) } } // A dynamic setting applied live deliberately leaves the serving copy alone, so // the two files differ by design. Read back under the startup spelling the // catalog does not carry, that difference would classify as static and report a // pending restart that nothing could ever clear. func TestMariaDBEngine_ALiveTimeZoneChangeLeavesNothingPendingRestart(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := os.WriteFile(engine.params.servingPath(), []byte("[mysqld]\ndefault_time_zone = 'SYSTEM'\n"), 0o600); err != nil { t.Fatalf("seed the serving copy: %v", err) } pending, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{ {Name: "time_zone", Value: "+10:00"}, }) if err != nil { t.Fatalf("ApplyParameters: %v", err) } if len(pending) != 0 { t.Errorf("pending = %v, want nothing: time_zone is dynamic and was applied live", pending) } } // The list is customer-facing, so a static setting still has to be reported // under the name the customer set rather than the engine's startup spelling. func TestMariaDBEngine_PendingRestartReportsCatalogNames(t *testing.T) { runner := &recordingRunner{} engine := newTestMariaDBEngine(t, runner.run) if err := os.WriteFile(engine.params.servingPath(), []byte("[mysqld]\ninnodb_buffer_pool_size = '536870912'\ndefault_time_zone = 'SYSTEM'\n"), 0o600); err != nil { t.Fatalf("seed the serving copy: %v", err) } pending, err := engine.ApplyParameters(context.Background(), []handlers_rds.Parameter{ {Name: "innodb_buffer_pool_size", Value: "1073741824"}, {Name: "time_zone", Value: "SYSTEM"}, }) if err != nil { t.Fatalf("ApplyParameters: %v", err) } if !slices.Equal(pending, []string{"innodb_buffer_pool_size"}) { t.Errorf("pending = %v, want only the static setting under its catalog name", pending) } } // Every dynamic value the control plane can resolve has to render, at every // class: a catalog entry the SET GLOBAL path cannot express would otherwise // surface as a failed apply on a live instance rather than as a test failure on // the change that introduced it. func TestMariaDBEngine_EveryResolvedDynamicValueRenders(t *testing.T) { meta, err := handlers_rds.LookupEngine("mariadb") if err != nil { t.Fatalf("LookupEngine: %v", err) } rules := controlPlaneRulesFrom(meta) for _, class := range handlers_rds.SupportedInstanceClasses() { params, err := meta.ResolveEffectiveParameters(class, nil) if err != nil { t.Fatalf("ResolveEffectiveParameters(%s): %v", class, err) } for _, p := range params { if rules.isStatic(p.Name) { continue } if _, err := mariadbSetValue(rules.dataType(p.Name), p.Value); err != nil { t.Errorf("%s at %s: %v", p.Name, class, err) } } } }