feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite
This commit is contained in:
1 parent
81e299f09a
commit
d752a61e52
35 files changed
+2896
-76
No files matched your search
@@ -0,0 +1,55 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupFile struct {
|
||||
path string
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
func newBackupTarget(directory string, now time.Time, extension string) (string, error) {
|
||||
if err := os.MkdirAll(directory, os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(directory, backupFilePrefix+now.UTC().Format(backupTimeLayout)+extension)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", errBackupExists
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func pruneBackups(directory string, extension string) error {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), extension) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, backupFile{path: filepath.Join(directory, entry.Name()), modified: info.ModTime()})
|
||||
}
|
||||
slices.SortFunc(files, func(a, b backupFile) int {
|
||||
if byTime := b.modified.Compare(a.modified); byTime != 0 {
|
||||
return byTime
|
||||
}
|
||||
return strings.Compare(b.path, a.path)
|
||||
})
|
||||
for _, stale := range files[min(backupsToKeep, len(files)):] {
|
||||
if err := os.Remove(stale.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -8,9 +8,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PoolSettings struct {
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Driver string
|
||||
Source string
|
||||
Pool *PoolSettings
|
||||
}
|
||||
|
||||
type Driver interface {
|
||||
@@ -20,6 +27,7 @@ type Driver interface {
|
||||
|
||||
var drivers = map[string]Driver{
|
||||
DriverSQLite: sqliteDriver{},
|
||||
DriverMySQL: mysqlDriver{},
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
@@ -36,9 +44,25 @@ func Connect(config Config) (*Connection, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Pool != nil {
|
||||
if err := applyPool(db, *config.Pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Connection{DB: db, driver: driver}, nil
|
||||
}
|
||||
|
||||
func applyPool(db *gorm.DB, pool PoolSettings) error {
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDb.SetMaxOpenConns(pool.MaxOpenConns)
|
||||
sqlDb.SetMaxIdleConns(pool.MaxIdleConns)
|
||||
sqlDb.SetConnMaxLifetime(pool.ConnMaxLifetime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Connection) NewBackup(directory string, now func() time.Time) domainUpdate.DatabaseBackup {
|
||||
return c.driver.NewBackup(c.DB, directory, now)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"bufio"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dumpFileExtension = ".sql"
|
||||
dumpPartialFileExtension = ".partial"
|
||||
)
|
||||
|
||||
type DumpBackup struct {
|
||||
open func() (DumpSource, error)
|
||||
directory string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var _ domain.DatabaseBackup = &DumpBackup{}
|
||||
|
||||
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
|
||||
return &DumpBackup{open: open, directory: directory, now: now}
|
||||
}
|
||||
|
||||
func (b *DumpBackup) Backup() (string, error) {
|
||||
target, err := newBackupTarget(b.directory, b.now(), dumpFileExtension)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := b.writeAtomically(target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pruneBackups(b.directory, dumpFileExtension); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (b *DumpBackup) writeAtomically(target string) error {
|
||||
partial := target + dumpPartialFileExtension
|
||||
if err := b.writePartial(partial); err != nil {
|
||||
_ = os.Remove(partial)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(partial, target); err != nil {
|
||||
_ = os.Remove(partial)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DumpBackup) writePartial(partial string) error {
|
||||
source, err := b.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
file, err := os.OpenFile(partial, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffered := bufio.NewWriter(file)
|
||||
if err := WriteDump(buffered, source); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := buffered.Flush(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
DriverMySQL = "mysql"
|
||||
|
||||
mysqlCharset = "utf8mb4"
|
||||
mysqlLocation = "UTC"
|
||||
mysqlCollation = "utf8mb4_bin"
|
||||
mysqlConnectTimeout = 5 * time.Second
|
||||
mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
|
||||
mysqlTableOptionsKey = "gorm:table_options"
|
||||
redactedPassword = "***"
|
||||
)
|
||||
|
||||
type mysqlDriver struct{}
|
||||
|
||||
type MySQLConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
TLS string
|
||||
}
|
||||
|
||||
func MySQLDSN(config MySQLConfig) string {
|
||||
dsn := mysqldriver.NewConfig()
|
||||
dsn.User = config.User
|
||||
dsn.Passwd = config.Password
|
||||
dsn.Net = "tcp"
|
||||
dsn.Addr = net.JoinHostPort(config.Host, config.Port)
|
||||
dsn.DBName = config.Name
|
||||
dsn.TLSConfig = config.TLS
|
||||
dsn.ParseTime = true
|
||||
dsn.ClientFoundRows = true
|
||||
dsn.Collation = mysqlCollation
|
||||
dsn.Timeout = mysqlConnectTimeout
|
||||
dsn.Params = map[string]string{
|
||||
"charset": mysqlCharset,
|
||||
"loc": mysqlLocation,
|
||||
"sql_mode": mysqlSessionSQLMode,
|
||||
}
|
||||
return dsn.FormatDSN()
|
||||
}
|
||||
|
||||
func (mysqlDriver) Open(source string) (*gorm.DB, error) {
|
||||
parsed, err := mysqldriver.ParseDSN(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the mysql connection settings are invalid")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(source), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot use the mysql server at %s: %s", parsed.Addr, redact(err.Error(), parsed.Passwd))
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (mysqlDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
|
||||
return NewMySQLBackup(db, directory, now)
|
||||
}
|
||||
|
||||
func redact(message string, secret string) string {
|
||||
if secret == "" {
|
||||
return message
|
||||
}
|
||||
return strings.ReplaceAll(message, secret, redactedPassword)
|
||||
}
|
||||
|
||||
func tableOptionsFor(db *gorm.DB) *gorm.DB {
|
||||
if db.Dialector.Name() == DriverMySQL {
|
||||
return db.Set(mysqlTableOptionsKey, "DEFAULT CHARSET="+mysqlCharset+" COLLATE="+mysqlCollation)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
dumpTimeLayout = "2006-01-02 15:04:05.000000"
|
||||
zeroDumpTime = "0000-00-00 00:00:00"
|
||||
)
|
||||
|
||||
func NewMySQLBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
|
||||
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
|
||||
}
|
||||
|
||||
type mysqlDumpSource struct {
|
||||
tx *gorm.DB
|
||||
}
|
||||
|
||||
var _ DumpSource = &mysqlDumpSource{}
|
||||
|
||||
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
|
||||
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
return &mysqlDumpSource{tx: tx}, nil
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) Tables() ([]string, error) {
|
||||
var tables []string
|
||||
err := s.tx.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name").Scan(&tables).Error
|
||||
return tables, err
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) CreateStatement(table string) (string, error) {
|
||||
var name, statement string
|
||||
row := s.tx.Raw("SHOW CREATE TABLE " + quoteIdentifier(table)).Row()
|
||||
if err := row.Scan(&name, &statement); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return statement, nil
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) Rows(table string) (DumpRows, error) {
|
||||
rows, err := s.tx.Raw("SELECT * FROM " + quoteIdentifier(table)).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mysqlDumpRows{rows: rows}, nil
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) Close() error {
|
||||
return s.tx.Rollback().Error
|
||||
}
|
||||
|
||||
type mysqlDumpRows struct {
|
||||
rows *sql.Rows
|
||||
}
|
||||
|
||||
var _ DumpRows = &mysqlDumpRows{}
|
||||
|
||||
func (r *mysqlDumpRows) Columns() ([]DumpColumn, error) {
|
||||
types, err := r.rows.ColumnTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := make([]DumpColumn, len(types))
|
||||
for index, columnType := range types {
|
||||
columns[index] = DumpColumn{Name: columnType.Name(), Type: columnType.DatabaseTypeName()}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Next() bool {
|
||||
return r.rows.Next()
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Values() ([][]byte, error) {
|
||||
columnCount, err := r.rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanned := make([]any, len(columnCount))
|
||||
targets := make([]any, len(columnCount))
|
||||
for index := range scanned {
|
||||
targets[index] = &scanned[index]
|
||||
}
|
||||
if err := r.rows.Scan(targets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([][]byte, len(scanned))
|
||||
for index, value := range scanned {
|
||||
values[index] = dumpBytes(value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Err() error {
|
||||
return r.rows.Err()
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Close() error {
|
||||
return r.rows.Close()
|
||||
}
|
||||
|
||||
func dumpBytes(value any) []byte {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []byte:
|
||||
return append([]byte{}, typed...)
|
||||
case string:
|
||||
return []byte(typed)
|
||||
case time.Time:
|
||||
return []byte(formatDumpTime(typed))
|
||||
default:
|
||||
return []byte(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func formatDumpTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return zeroDumpTime
|
||||
}
|
||||
return value.UTC().Format(dumpTimeLayout)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DumpColumn struct {
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
type DumpRows interface {
|
||||
Columns() ([]DumpColumn, error)
|
||||
Next() bool
|
||||
Values() ([][]byte, error)
|
||||
Err() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type DumpSource interface {
|
||||
Tables() ([]string, error)
|
||||
CreateStatement(table string) (string, error)
|
||||
Rows(table string) (DumpRows, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
var dumpPreamble = []string{
|
||||
"SET NAMES utf8mb4;",
|
||||
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
|
||||
"SET foreign_key_checks = 0;",
|
||||
}
|
||||
|
||||
var dumpPostamble = []string{
|
||||
"SET foreign_key_checks = 1;",
|
||||
}
|
||||
|
||||
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
|
||||
|
||||
var textEscapes = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`'`, `\'`,
|
||||
"\x00", `\0`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"\x1a", `\Z`,
|
||||
)
|
||||
|
||||
func WriteDump(output io.Writer, source DumpSource) error {
|
||||
if err := writeStatements(output, dumpPreamble); err != nil {
|
||||
return err
|
||||
}
|
||||
tables, err := source.Tables()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, table := range tables {
|
||||
if err := writeTable(output, source, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeStatements(output, dumpPostamble)
|
||||
}
|
||||
|
||||
func writeStatements(output io.Writer, statements []string) error {
|
||||
for _, statement := range statements {
|
||||
if _, err := fmt.Fprintln(output, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTable(output io.Writer, source DumpSource, table string) error {
|
||||
create, err := source.CreateStatement(table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(output, "%s;\n", create); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := source.Rows(table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
return writeRows(output, table, rows)
|
||||
}
|
||||
|
||||
func writeRows(output io.Writer, table string, rows DumpRows) error {
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
insertPrefix := insertStatementPrefix(table, columns)
|
||||
for rows.Next() {
|
||||
values, err := rows.Values()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func insertStatementPrefix(table string, columns []DumpColumn) string {
|
||||
names := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
names[index] = quoteIdentifier(column.Name)
|
||||
}
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", quoteIdentifier(table), strings.Join(names, ","))
|
||||
}
|
||||
|
||||
func joinLiterals(columns []DumpColumn, values [][]byte) string {
|
||||
literals := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
literals[index] = sqlLiteral(column.Type, values[index])
|
||||
}
|
||||
return strings.Join(literals, ",")
|
||||
}
|
||||
|
||||
func quoteIdentifier(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func sqlLiteral(databaseType string, value []byte) string {
|
||||
if value == nil {
|
||||
return "NULL"
|
||||
}
|
||||
switch {
|
||||
case isNumericType(databaseType) && plainNumber.Match(value):
|
||||
return string(value)
|
||||
case isBinaryType(databaseType):
|
||||
return hexLiteral(value)
|
||||
default:
|
||||
return "'" + textEscapes.Replace(string(value)) + "'"
|
||||
}
|
||||
}
|
||||
|
||||
func hexLiteral(value []byte) string {
|
||||
if len(value) == 0 {
|
||||
return "''"
|
||||
}
|
||||
return "0x" + hex.EncodeToString(value)
|
||||
}
|
||||
|
||||
func isNumericType(databaseType string) bool {
|
||||
upper := strings.ToUpper(databaseType)
|
||||
for _, suffix := range []string{"INT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "YEAR"} {
|
||||
if strings.HasSuffix(upper, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBinaryType(databaseType string) bool {
|
||||
upper := strings.ToUpper(databaseType)
|
||||
for _, suffix := range []string{"BLOB", "BINARY", "BIT", "GEOMETRY"} {
|
||||
if strings.HasSuffix(upper, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -34,54 +31,16 @@ func NewSQLiteBackup(db *gorm.DB, directory string, now func() time.Time) *SQLit
|
||||
}
|
||||
|
||||
func (b *SQLiteBackup) Backup() (string, error) {
|
||||
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
|
||||
target, err := newBackupTarget(b.directory, b.now(), backupFileExtension)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(b.directory, backupFilePrefix+b.now().UTC().Format(backupTimeLayout)+backupFileExtension)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", errBackupExists
|
||||
}
|
||||
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
|
||||
_ = os.Remove(target)
|
||||
return "", err
|
||||
}
|
||||
if err := b.prune(); err != nil {
|
||||
if err := pruneBackups(b.directory, backupFileExtension); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
type backupFile struct {
|
||||
path string
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
func (b *SQLiteBackup) prune() error {
|
||||
entries, err := os.ReadDir(b.directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backupFileExtension) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, backupFile{path: filepath.Join(b.directory, entry.Name()), modified: info.ModTime()})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].modified.Equal(files[j].modified) {
|
||||
return files[i].path > files[j].path
|
||||
}
|
||||
return files[i].modified.After(files[j].modified)
|
||||
})
|
||||
for _, stale := range files[min(backupsToKeep, len(files)):] {
|
||||
if err := os.Remove(stale.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user