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 }