Scert/backend/internal/testsupport/mysqltest/server.go

271 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mysqltest
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type Server struct {
root string
binary string
version string
port string
password string
tlsName string
admin *sql.DB
command *exec.Cmd
exit chan error
sequence int
}
// 只接受程序目录与产物目录;不接受现有实例连接串,也不读取机器上的默认配置。
func New(t *testing.T) *Server {
t.Helper()
bin := os.Getenv("SCERT_MYSQL_TEST_BIN")
if bin == "" {
t.Skip("未启用 MySQL 隔离实库测试")
}
root := os.Getenv("SCERT_MYSQL_TEST_ROOT")
if !filepath.IsAbs(bin) || !filepath.IsAbs(root) {
t.Fatal("MySQL 实库测试要求程序与隔离产物目录使用绝对路径")
}
binary := filepath.Join(bin, "mysqld")
if runtime.GOOS == "windows" {
binary += ".exe"
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, binary, "--no-defaults", "--version").Output()
if err != nil {
t.Fatal("无法读取 MySQL 服务端版本")
}
version := string(output)
if !strings.Contains(version, "Ver 5.7.") && !strings.Contains(version, "Ver 8.0.") {
t.Fatal("隔离测试仅接受明确支持目标 MySQL 5.7 或 8.0")
}
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
root, err = os.MkdirTemp(root, "scert-mysql-integration-")
if err != nil {
t.Fatal(err)
}
password := make([]byte, 32)
if _, err := rand.Read(password); err != nil {
t.Fatal(err)
}
f := &Server{root: root, binary: binary, version: version, password: hex.EncodeToString(password)}
t.Logf("独立 MySQL 程序:%s;产物保留于:%s", strings.TrimSpace(version), root)
f.tlsName = "scert-mysql-" + filepath.Base(root)
f.createTLS(t)
t.Cleanup(func() { mysql.DeregisterTLSConfig(f.tlsName) })
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f.port = strconv.Itoa(listener.Addr().(*net.TCPAddr).Port)
if err := listener.Close(); err != nil {
t.Fatal(err)
}
// 初始化阶段不启动监听;首次启动在开放连接前为所有测试管理账户设随机密码。
initCtx, initCancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer initCancel()
args := append(f.baseArgs(), "--initialize-insecure")
if err := exec.CommandContext(initCtx, binary, args...).Run(); err != nil {
t.Fatal("初始化独立 MySQL 失败,请检查保留目录中的服务端日志")
}
bootstrap := "ALTER USER 'root'@'localhost' IDENTIFIED BY '" + f.password + "';\n" +
"CREATE USER 'scert_harness'@'127.0.0.1' IDENTIFIED BY '" + f.password + "' REQUIRE SSL;\n" +
"GRANT ALL PRIVILEGES ON *.* TO 'scert_harness'@'127.0.0.1' WITH GRANT OPTION;\n"
f.write(t, "bootstrap.sql", []byte(bootstrap))
defer f.write(t, "bootstrap.sql", nil)
t.Cleanup(func() { f.Stop(t) })
f.Start(t, true)
// 初始口令文件不进入证据;保留空文件而非删除测试产物。
f.write(t, "bootstrap.sql", nil)
return f
}
func (f *Server) baseArgs() []string {
return []string{"--no-defaults", "--basedir=" + filepath.Dir(filepath.Dir(f.binary)),
"--datadir=" + filepath.Join(f.root, "data"), "--log-error=" + filepath.Join(f.root, "mysql.log"),
"--innodb-buffer-pool-size=32M", "--innodb-log-file-size=8M", "--performance-schema=OFF"}
}
func (f *Server) Start(t *testing.T, bootstrap bool) {
t.Helper()
args := append(f.baseArgs(), "--bind-address=127.0.0.1", "--port="+f.port,
"--skip-name-resolve", "--require-secure-transport=ON", "--max-connections=20",
"--ssl-ca="+filepath.Join(f.root, "server.crt"), "--ssl-cert="+filepath.Join(f.root, "server.crt"),
"--ssl-key="+filepath.Join(f.root, "server.key"), "--local-infile=OFF", "--secure-file-priv=NULL",
"--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci")
if strings.Contains(f.version, "Ver 8.0.") {
args = append(args, "--mysqlx=OFF")
}
if runtime.GOOS != "windows" {
args = append(args, "--socket="+filepath.Join(f.root, "mysql.sock"))
}
if bootstrap {
args = append(args, "--init-file="+filepath.Join(f.root, "bootstrap.sql"))
}
f.command = exec.Command(f.binary, args...)
if err := f.command.Start(); err != nil {
f.command = nil
t.Fatal("启动独立 MySQL 失败")
}
f.exit = make(chan error, 1)
command := f.command
go func() { f.exit <- command.Wait() }()
var err error
f.admin, err = sql.Open("mysql", f.DSN("scert_harness", ""))
if err != nil {
t.Fatal("创建独立 MySQL 管理连接失败")
}
deadline := time.Now().Add(time.Minute)
for time.Now().Before(deadline) {
select {
case <-f.exit:
f.command = nil
t.Fatal("独立 MySQL 已退出,请检查保留日志")
default:
}
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
err := f.admin.PingContext(ctx)
cancel()
if err == nil {
var version string
if err := f.admin.QueryRowContext(t.Context(), "SELECT VERSION()").Scan(&version); err != nil {
t.Fatal(err)
}
t.Logf("实连版本:%s;仅监听 127.0.0.1:%s;严格 TLS", version, f.port)
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatal("独立 MySQL 未在时限内就绪,请检查保留日志;不降级 TLS")
}
func (f *Server) Stop(t *testing.T) {
t.Helper()
if f.command == nil {
if f.admin != nil {
_ = f.admin.Close()
}
return
}
if f.admin != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = f.admin.ExecContext(ctx, "SHUTDOWN")
cancel()
_ = f.admin.Close()
}
select {
case err := <-f.exit:
if err != nil {
t.Errorf("独立 MySQL 未正常退出:%v", err)
}
case <-time.After(15 * time.Second):
_ = f.command.Process.Kill()
<-f.exit
t.Error("独立 MySQL 停止超时,已终止本测试启动的进程")
}
f.command = nil
}
func (f *Server) DSN(user, name string) string {
cfg := mysql.NewConfig()
cfg.User, cfg.Passwd = user, f.password
cfg.Net, cfg.Addr = "tcp", net.JoinHostPort("127.0.0.1", f.port)
cfg.DBName, cfg.TLSConfig = name, f.tlsName
cfg.Timeout, cfg.ReadTimeout, cfg.WriteTimeout = 3*time.Second, 10*time.Second, 10*time.Second
cfg.ParseTime = true
return cfg.FormatDSN()
}
func (f *Server) Database(t *testing.T) config.DatabaseConfig {
t.Helper()
f.sequence++
name := fmt.Sprintf("scert_test_%d", f.sequence)
user := fmt.Sprintf("scert_user_%d", f.sequence)
for _, statement := range []string{
"CREATE DATABASE " + name + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
"CREATE USER '" + user + "'@'127.0.0.1' IDENTIFIED BY '" + f.password + "' REQUIRE SSL",
"GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, TRIGGER, EVENT ON " + name + ".* TO '" + user + "'@'127.0.0.1'",
"GRANT PROCESS ON *.* TO '" + user + "'@'127.0.0.1'",
} {
if _, err := f.admin.ExecContext(t.Context(), statement); err != nil {
t.Fatal("创建隔离数据库及库级账户失败")
}
}
// 备份必须能发现其他定义者的例程,不能将过滤后的空列表当作完整目录。
grant := "GRANT SELECT ON mysql.proc TO '" + user + "'@'127.0.0.1'"
if f.IsMySQL8() {
grant = "GRANT SHOW_ROUTINE ON *.* TO '" + user + "'@'127.0.0.1'"
}
if _, err := f.admin.ExecContext(t.Context(), grant); err != nil {
t.Fatal("授予隔离账户例程可见权限失败")
}
return config.DatabaseConfig{Driver: "mysql", DSN: f.DSN(user, name), MaxOpenConns: 2, MaxIdleConns: 1}
}
func (f *Server) Root() string { return f.root }
func (f *Server) TLSName() string { return f.tlsName }
func (f *Server) Admin() *sql.DB { return f.admin }
func (f *Server) write(t *testing.T, name string, data []byte) {
t.Helper()
if err := os.WriteFile(filepath.Join(f.root, name), data, 0o600); err != nil {
t.Fatal(err)
}
}
func (f *Server) createTLS(t *testing.T) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
certificate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert isolated MySQL"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true,
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}}
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
f.write(t, "server.crt", encoded)
f.write(t, "server.key", pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(encoded) {
t.Fatal("解析隔离测试证书失败")
}
if err := mysql.RegisterTLSConfig(f.tlsName, &tls.Config{RootCAs: roots, ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12}); err != nil {
t.Fatal(err)
}
}