github.com/letsencrypt/boulder@v0.20251208.0/sa/database.go (about)

     1  package sa
     2  
     3  import (
     4  	"database/sql"
     5  	"fmt"
     6  	"time"
     7  
     8  	"github.com/go-sql-driver/mysql"
     9  	"github.com/prometheus/client_golang/prometheus"
    10  
    11  	"github.com/letsencrypt/borp"
    12  
    13  	"github.com/letsencrypt/boulder/cmd"
    14  	"github.com/letsencrypt/boulder/core"
    15  	boulderDB "github.com/letsencrypt/boulder/db"
    16  	"github.com/letsencrypt/boulder/features"
    17  	blog "github.com/letsencrypt/boulder/log"
    18  )
    19  
    20  // DbSettings contains settings for the database/sql driver. The zero
    21  // value of each field means use the default setting from database/sql.
    22  // ConnMaxIdleTime and ConnMaxLifetime should be set lower than their
    23  // mariab counterparts interactive_timeout and wait_timeout.
    24  type DbSettings struct {
    25  	// MaxOpenConns sets the maximum number of open connections to the
    26  	// database. If MaxIdleConns is greater than 0 and MaxOpenConns is
    27  	// less than MaxIdleConns, then MaxIdleConns will be reduced to
    28  	// match the new MaxOpenConns limit. If n < 0, then there is no
    29  	// limit on the number of open connections.
    30  	MaxOpenConns int
    31  
    32  	// MaxIdleConns sets the maximum number of connections in the idle
    33  	// connection pool. If MaxOpenConns is greater than 0 but less than
    34  	// MaxIdleConns, then MaxIdleConns will be reduced to match the
    35  	// MaxOpenConns limit. If n < 0, no idle connections are retained.
    36  	MaxIdleConns int
    37  
    38  	// ConnMaxLifetime sets the maximum amount of time a connection may
    39  	// be reused. Expired connections may be closed lazily before reuse.
    40  	// If d < 0, connections are not closed due to a connection's age.
    41  	ConnMaxLifetime time.Duration
    42  
    43  	// ConnMaxIdleTime sets the maximum amount of time a connection may
    44  	// be idle. Expired connections may be closed lazily before reuse.
    45  	// If d < 0, connections are not closed due to a connection's idle
    46  	// time.
    47  	ConnMaxIdleTime time.Duration
    48  }
    49  
    50  // InitWrappedDb constructs a wrapped borp mapping object with the provided
    51  // settings. If scope is non-nil, Prometheus metrics will be exported. If logger
    52  // is non-nil, SQL debug-level logging will be enabled. The only required parameter
    53  // is config.
    54  func InitWrappedDb(config cmd.DBConfig, scope prometheus.Registerer, logger blog.Logger) (*boulderDB.WrappedMap, error) {
    55  	url, err := config.URL()
    56  	if err != nil {
    57  		return nil, fmt.Errorf("failed to load DBConnect URL: %s", err)
    58  	}
    59  
    60  	settings := DbSettings{
    61  		MaxOpenConns:    config.MaxOpenConns,
    62  		MaxIdleConns:    config.MaxIdleConns,
    63  		ConnMaxLifetime: config.ConnMaxLifetime.Duration,
    64  		ConnMaxIdleTime: config.ConnMaxIdleTime.Duration,
    65  	}
    66  
    67  	mysqlConfig, err := mysql.ParseDSN(url)
    68  	if err != nil {
    69  		return nil, err
    70  	}
    71  
    72  	dbMap, err := newDbMapFromMySQLConfig(mysqlConfig, settings, scope, logger)
    73  	if err != nil {
    74  		return nil, err
    75  	}
    76  
    77  	return dbMap, nil
    78  }
    79  
    80  // DBMapForTest creates a wrapped root borp mapping object. Create one of these for
    81  // each database schema you wish to map. Each DbMap contains a list of mapped
    82  // tables. It automatically maps the tables for the primary parts of Boulder
    83  // around the Storage Authority.
    84  func DBMapForTest(dbConnect string) (*boulderDB.WrappedMap, error) {
    85  	return DBMapForTestWithLog(dbConnect, nil)
    86  }
    87  
    88  // DBMapForTestWithLog does the same as DBMapForTest but also routes the debug logs
    89  // from the database driver to the given log (usually a `blog.NewMock`).
    90  func DBMapForTestWithLog(dbConnect string, log blog.Logger) (*boulderDB.WrappedMap, error) {
    91  	var err error
    92  	var config *mysql.Config
    93  
    94  	config, err = mysql.ParseDSN(dbConnect)
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  
    99  	return newDbMapFromMySQLConfig(config, DbSettings{}, nil, log)
   100  }
   101  
   102  // sqlOpen is used in the tests to check that the arguments are properly
   103  // transformed
   104  var sqlOpen = func(dbType, connectStr string) (*sql.DB, error) {
   105  	return sql.Open(dbType, connectStr)
   106  }
   107  
   108  // setMaxOpenConns is also used so that we can replace it for testing.
   109  var setMaxOpenConns = func(db *sql.DB, maxOpenConns int) {
   110  	if maxOpenConns != 0 {
   111  		db.SetMaxOpenConns(maxOpenConns)
   112  	}
   113  }
   114  
   115  // setMaxIdleConns is also used so that we can replace it for testing.
   116  var setMaxIdleConns = func(db *sql.DB, maxIdleConns int) {
   117  	if maxIdleConns != 0 {
   118  		db.SetMaxIdleConns(maxIdleConns)
   119  	}
   120  }
   121  
   122  // setConnMaxLifetime is also used so that we can replace it for testing.
   123  var setConnMaxLifetime = func(db *sql.DB, connMaxLifetime time.Duration) {
   124  	if connMaxLifetime != 0 {
   125  		db.SetConnMaxLifetime(connMaxLifetime)
   126  	}
   127  }
   128  
   129  // setConnMaxIdleTime is also used so that we can replace it for testing.
   130  var setConnMaxIdleTime = func(db *sql.DB, connMaxIdleTime time.Duration) {
   131  	if connMaxIdleTime != 0 {
   132  		db.SetConnMaxIdleTime(connMaxIdleTime)
   133  	}
   134  }
   135  
   136  // newDbMapFromMySQLConfig opens a database connection given the provided *mysql.Config, plus some Boulder-specific
   137  // required and default settings, plus some additional config in the sa.DbSettings object. The sa.DbSettings object
   138  // is usually provided from JSON config.
   139  //
   140  // This function also:
   141  //   - pings the database (and errors if it's unreachable)
   142  //   - wraps the connection in a borp.DbMap so we can use the handy Get/Insert methods borp provides
   143  //   - wraps that in a db.WrappedMap to get more useful error messages
   144  //
   145  // If logger is non-nil, it will receive debug log messages from borp.
   146  // If scope is non-nil, it will be used to register Prometheus metrics.
   147  func newDbMapFromMySQLConfig(config *mysql.Config, settings DbSettings, scope prometheus.Registerer, logger blog.Logger) (*boulderDB.WrappedMap, error) {
   148  	err := adjustMySQLConfig(config)
   149  	if err != nil {
   150  		return nil, err
   151  	}
   152  
   153  	db, err := sqlOpen("mysql", config.FormatDSN())
   154  	if err != nil {
   155  		return nil, err
   156  	}
   157  	if err = db.Ping(); err != nil {
   158  		return nil, err
   159  	}
   160  	setMaxOpenConns(db, settings.MaxOpenConns)
   161  	setMaxIdleConns(db, settings.MaxIdleConns)
   162  	setConnMaxLifetime(db, settings.ConnMaxLifetime)
   163  	setConnMaxIdleTime(db, settings.ConnMaxIdleTime)
   164  
   165  	if scope != nil {
   166  		err = initDBMetrics(db, scope, settings, config.Addr, config.User)
   167  		if err != nil {
   168  			return nil, fmt.Errorf("while initializing metrics: %w", err)
   169  		}
   170  	}
   171  
   172  	dialect := borp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"}
   173  	dbmap := &borp.DbMap{Db: db, Dialect: dialect, TypeConverter: BoulderTypeConverter{}}
   174  
   175  	if logger != nil {
   176  		dbmap.TraceOn("SQL: ", &SQLLogger{logger})
   177  	}
   178  
   179  	initTables(dbmap)
   180  	return boulderDB.NewWrappedMap(dbmap), nil
   181  }
   182  
   183  // adjustMySQLConfig sets certain flags that we want on every connection.
   184  func adjustMySQLConfig(conf *mysql.Config) error {
   185  	// Required to turn DATETIME fields into time.Time
   186  	conf.ParseTime = true
   187  
   188  	// Required to make UPDATE return the number of rows matched,
   189  	// instead of the number of rows changed by the UPDATE.
   190  	conf.ClientFoundRows = true
   191  
   192  	if conf.Params == nil {
   193  		conf.Params = make(map[string]string)
   194  	}
   195  
   196  	// If a given parameter is not already set in conf.Params from the DSN, set it.
   197  	setDefault := func(name, value string) {
   198  		_, ok := conf.Params[name]
   199  		if !ok {
   200  			conf.Params[name] = value
   201  		}
   202  	}
   203  
   204  	// Ensures that MySQL/MariaDB warnings are treated as errors. This
   205  	// avoids a number of nasty edge conditions we could wander into.
   206  	// Common things this discovers includes places where data being sent
   207  	// had a different type than what is in the schema, strings being
   208  	// truncated, writing null to a NOT NULL column, and so on. See
   209  	// <https://dev.mysql.com/doc/refman/5.0/en/sql-mode.html#sql-mode-strict>.
   210  	setDefault("sql_mode", "'STRICT_ALL_TABLES'")
   211  
   212  	// Omit max_statement_time and max_execution_time from the DSN. Query
   213  	// timeouts are managed exclusively by ProxySQL and/or Vitess.
   214  	delete(conf.Params, "max_statement_time")
   215  	delete(conf.Params, "max_execution_time")
   216  
   217  	// Finally, perform validation over all variables set by the DSN and via Boulder.
   218  	for k, v := range conf.Params {
   219  		err := checkMariaDBSystemVariables(k, v)
   220  		if err != nil {
   221  			return err
   222  		}
   223  	}
   224  
   225  	return nil
   226  }
   227  
   228  // SQLLogger adapts the Boulder Logger to a format borp can use.
   229  type SQLLogger struct {
   230  	blog.Logger
   231  }
   232  
   233  // Printf adapts the Logger to borp's interface
   234  func (log *SQLLogger) Printf(format string, v ...any) {
   235  	log.Debugf(format, v...)
   236  }
   237  
   238  // initTables constructs the table map for the ORM.
   239  // NOTE: For tables with an auto-increment primary key (SetKeys(true, ...)),
   240  // it is very important to declare them as a such here. It produces a side
   241  // effect in Insert() where the inserted object has its id field set to the
   242  // autoincremented value that resulted from the insert. See
   243  // https://godoc.org/github.com/coopernurse/borp#DbMap.Insert
   244  func initTables(dbMap *borp.DbMap) {
   245  	regTable := dbMap.AddTableWithName(regModel{}, "registrations").SetKeys(true, "ID")
   246  
   247  	regTable.ColMap("Key").SetNotNull(true)
   248  	regTable.ColMap("KeySHA256").SetNotNull(true).SetUnique(true)
   249  	dbMap.AddTableWithName(issuedNameModel{}, "issuedNames").SetKeys(true, "ID")
   250  	dbMap.AddTableWithName(core.Certificate{}, "certificates").SetKeys(true, "ID")
   251  	dbMap.AddTableWithName(certificateStatusModel{}, "certificateStatus").SetKeys(true, "ID")
   252  	dbMap.AddTableWithName(fqdnSet{}, "fqdnSets").SetKeys(true, "ID")
   253  	tableMap := dbMap.AddTableWithName(orderModel{}, "orders").SetKeys(true, "ID")
   254  	if !features.Get().StoreARIReplacesInOrders {
   255  		tableMap.ColMap("Replaces").SetTransient(true)
   256  	}
   257  	if !features.Get().StoreAuthzsInOrders {
   258  		tableMap.ColMap("Authzs").SetTransient(true)
   259  	}
   260  
   261  	dbMap.AddTableWithName(orderToAuthzModel{}, "orderToAuthz").SetKeys(false, "OrderID", "AuthzID")
   262  	dbMap.AddTableWithName(orderFQDNSet{}, "orderFqdnSets").SetKeys(true, "ID")
   263  	dbMap.AddTableWithName(authzModel{}, "authz2").SetKeys(true, "ID")
   264  	dbMap.AddTableWithName(orderToAuthzModel{}, "orderToAuthz2").SetKeys(false, "OrderID", "AuthzID")
   265  	dbMap.AddTableWithName(recordedSerialModel{}, "serials").SetKeys(true, "ID")
   266  	dbMap.AddTableWithName(lintingCertModel{}, "precertificates").SetKeys(true, "ID")
   267  	dbMap.AddTableWithName(keyHashModel{}, "keyHashToSerial").SetKeys(true, "ID")
   268  	dbMap.AddTableWithName(incidentModel{}, "incidents").SetKeys(true, "ID")
   269  	dbMap.AddTable(incidentSerialModel{})
   270  	dbMap.AddTableWithName(crlShardModel{}, "crlShards").SetKeys(true, "ID")
   271  	dbMap.AddTableWithName(revokedCertModel{}, "revokedCertificates").SetKeys(true, "ID")
   272  	dbMap.AddTableWithName(replacementOrderModel{}, "replacementOrders").SetKeys(true, "ID")
   273  	dbMap.AddTableWithName(pausedModel{}, "paused")
   274  	dbMap.AddTableWithName(overrideModel{}, "overrides").SetKeys(false, "limitEnum", "bucketKey")
   275  
   276  	// Read-only maps used for selecting subsets of columns.
   277  	dbMap.AddTableWithName(CertStatusMetadata{}, "certificateStatus")
   278  	dbMap.AddTableWithName(crlEntryModel{}, "certificateStatus")
   279  }