backup jobs work now with sql dumps too

This commit is contained in:
Peter Li 2026-02-07 21:22:01 -08:00
parent e5225a1353
commit 2ce2d5d881
10 changed files with 419 additions and 20 deletions

1
.gitignore vendored
View File

@ -33,3 +33,4 @@ data/
.env.* .env.*
!.env.example !.env.example
backups/ backups/
repos/

View File

@ -67,6 +67,8 @@ func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site)
err = a.pullDirectoryTarget(ctx, job.ID, site, target, stageDir) err = a.pullDirectoryTarget(ctx, job.ID, site, target, stageDir)
case "sqlite_dump": case "sqlite_dump":
err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir) err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir)
case "mysql_dump":
err = pullMySQLTarget(ctx, site, target, stageDir)
default: default:
err = fmt.Errorf("unknown target mode: %s", target.Mode) err = fmt.Errorf("unknown target mode: %s", target.Mode)
} }
@ -106,7 +108,10 @@ func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site)
func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error { func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error {
sshCmd := fmt.Sprintf("ssh -p %d", site.Port) sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remote := fmt.Sprintf("%s@%s:%s/", site.SSHUser, site.Host, target.Path) remote, err := syncRemotePath(ctx, site, target.Path)
if err != nil {
return err
}
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
@ -173,6 +178,44 @@ func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.S
return nil return nil
} }
func syncRemotePath(ctx context.Context, site store.Site, path string) (string, error) {
pathType, err := remotePathType(ctx, site, path)
if err != nil {
return "", err
}
switch pathType {
case "dir":
return fmt.Sprintf("%s@%s:%s/", site.SSHUser, site.Host, path), nil
case "file":
return fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, path), nil
default:
return "", errors.New("target path is neither a directory nor a file")
}
}
func remotePathType(ctx context.Context, site store.Site, path string) (string, error) {
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
quoted := shellQuote(path)
remoteCmd := fmt.Sprintf("if [ -d %s ]; then echo dir; elif [ -f %s ]; then echo file; else echo missing; fi", quoted, quoted)
cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), target, remoteCmd)
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return "", errors.New(msg)
}
v := strings.TrimSpace(string(out))
if v == "" || v == "missing" {
return "", errors.New("target path not found")
}
return v, nil
}
func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error { func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error {
tmpBase := fmt.Sprintf("/tmp/satoru-backup-%d-%s.sqlite3", jobID, shortHash(target.Path)) tmpBase := fmt.Sprintf("/tmp/satoru-backup-%d-%s.sqlite3", jobID, shortHash(target.Path))
quotedDB := shellQuote(target.Path) quotedDB := shellQuote(target.Path)
@ -201,6 +244,36 @@ func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target
return nil return nil
} }
func pullMySQLTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid {
return errors.New("mysql target missing db host/db user/db name/db password")
}
tmpBase := fmt.Sprintf("/tmp/satoru-mysql-%s.sql", shortHash(target.Path+target.MySQLHost.String+target.MySQLDB.String))
remoteCmd := strings.Join([]string{
mysqlDumpCommand(target, false, tmpBase),
fmt.Sprintf("gzip -f -- %s", shellQuote(tmpBase)),
}, " && ")
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz")))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteGz := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpBase+".gz")
localFile := filepath.Join(stageDir, "mysql-dump.sql.gz")
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteGz, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz")))
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func runResticBackup(ctx context.Context, repoPath string, site store.Site, jobID int64, stagedPaths []string) error { func runResticBackup(ctx context.Context, repoPath string, site store.Site, jobID int64, stagedPaths []string) error {
if err := ensureResticRepo(ctx, repoPath); err != nil { if err := ensureResticRepo(ctx, repoPath); err != nil {
return err return err
@ -373,14 +446,30 @@ func latestSnapshotIDForSite(ctx context.Context, repoPath, siteUUID string) (st
} }
func targetStageDir(root, siteUUID string, target store.SiteTarget) string { func targetStageDir(root, siteUUID string, target store.SiteTarget) string {
hash := hashPath(target.Path) hash := hashPath(targetIdentity(target))
modeDir := "dir" modeDir := "dir"
if target.Mode == "sqlite_dump" { if target.Mode == "sqlite_dump" {
modeDir = "sqlite" modeDir = "sqlite"
} else if target.Mode == "mysql_dump" {
modeDir = "mysql"
} }
return filepath.Join(root, siteUUID, hash, modeDir) return filepath.Join(root, siteUUID, hash, modeDir)
} }
func targetIdentity(target store.SiteTarget) string {
parts := []string{target.Mode, target.Path}
if target.MySQLHost.Valid {
parts = append(parts, target.MySQLHost.String)
}
if target.MySQLUser.Valid {
parts = append(parts, target.MySQLUser.String)
}
if target.MySQLDB.Valid {
parts = append(parts, target.MySQLDB.String)
}
return strings.Join(parts, "|")
}
func hashPath(path string) string { func hashPath(path string) string {
sum := sha256.Sum256([]byte(path)) sum := sha256.Sum256([]byte(path))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])

View File

@ -182,9 +182,11 @@ func (a *app) runPreflightJob(ctx context.Context, job store.Job, site store.Sit
var err error var err error
switch t.Mode { switch t.Mode {
case "directory": case "directory":
err = sshCheck(ctx, site, fmt.Sprintf("[ -d %s ]", shellQuote(t.Path))) err = sshCheck(ctx, site, fmt.Sprintf("[ -d %s ] || [ -f %s ]", shellQuote(t.Path), shellQuote(t.Path)))
case "sqlite_dump": case "sqlite_dump":
err = sqlitePreflightCheck(ctx, site, t.Path) err = sqlitePreflightCheck(ctx, site, t.Path)
case "mysql_dump":
err = mysqlPreflightCheck(ctx, site, t)
default: default:
err = fmt.Errorf("unknown target mode: %s", t.Mode) err = fmt.Errorf("unknown target mode: %s", t.Mode)
} }
@ -240,6 +242,17 @@ func sqlitePreflightCheck(ctx context.Context, site store.Site, dbPath string) e
return sshCheck(ctx, site, cmd) return sshCheck(ctx, site, cmd)
} }
func mysqlPreflightCheck(ctx context.Context, site store.Site, target store.SiteTarget) error {
if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid {
return errors.New("mysql target missing db host/db user/db name/db password")
}
cmd := strings.Join([]string{
"mysqldump --version >/dev/null",
mysqlDumpCommand(target, true, ""),
}, " && ")
return sshCheck(ctx, site, cmd)
}
func (a *app) enqueuePreflightJob(ctx context.Context, siteID int64) (store.Job, error) { func (a *app) enqueuePreflightJob(ctx context.Context, siteID int64) (store.Job, error) {
job, err := a.store.CreateJob(ctx, siteID, jobTypePreflight) job, err := a.store.CreateJob(ctx, siteID, jobTypePreflight)
if err != nil { if err != nil {

View File

@ -80,9 +80,11 @@ func main() {
r.Post("/account/password", a.handlePasswordSubmit) r.Post("/account/password", a.handlePasswordSubmit)
r.Post("/sites", a.handleSiteCreate) r.Post("/sites", a.handleSiteCreate)
r.Post("/sites/{id}/run", a.handleSiteRun) r.Post("/sites/{id}/run", a.handleSiteRun)
r.Post("/sites/{id}/mysql-dumps", a.handleSiteAddMySQLDump)
r.Post("/sites/{id}/cancel", a.handleSiteCancel) r.Post("/sites/{id}/cancel", a.handleSiteCancel)
r.Post("/sites/{id}/restart", a.handleSiteRestart) r.Post("/sites/{id}/restart", a.handleSiteRestart)
r.Post("/jobs/{id}/cancel", a.handleJobCancel) r.Post("/jobs/{id}/cancel", a.handleJobCancel)
r.Post("/sites/{id}/targets/{targetID}/delete", a.handleSiteTargetDelete)
r.Post("/sites/{id}/update", a.handleSiteUpdate) r.Post("/sites/{id}/update", a.handleSiteUpdate)
r.Post("/sites/{id}/delete", a.handleSiteDelete) r.Post("/sites/{id}/delete", a.handleSiteDelete)
r.Get("/signup", a.handleSignupPage) r.Get("/signup", a.handleSignupPage)
@ -353,6 +355,46 @@ func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/?msg=job-queued", http.StatusSeeOther) http.Redirect(w, r, "/?msg=job-queued", http.StatusSeeOther)
} }
func (a *app) handleSiteAddMySQLDump(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
siteID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid site id", http.StatusBadRequest)
return
}
if _, err := a.store.SiteByID(r.Context(), siteID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "failed to load site", http.StatusInternalServerError)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
dbHost := strings.TrimSpace(r.FormValue("db_host"))
dbUser := strings.TrimSpace(r.FormValue("db_user"))
dbName := strings.TrimSpace(r.FormValue("db_name"))
dbPassword := r.FormValue("db_password")
if dbHost == "" || dbUser == "" || dbName == "" || dbPassword == "" {
http.Redirect(w, r, "/?msg=mysql-invalid", http.StatusSeeOther)
return
}
if err := a.store.AddMySQLDumpTarget(r.Context(), siteID, dbHost, dbUser, dbName, dbPassword); err != nil {
http.Error(w, "failed to add mysql dump target", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?msg=mysql-added", http.StatusSeeOther)
}
func (a *app) handleSiteCancel(w http.ResponseWriter, r *http.Request) { func (a *app) handleSiteCancel(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUserWithRollingSession(w, r); err != nil { if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)
@ -437,6 +479,32 @@ func (a *app) handleJobCancel(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (a *app) handleSiteTargetDelete(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
siteID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid site id", http.StatusBadRequest)
return
}
targetID, err := strconv.ParseInt(chi.URLParam(r, "targetID"), 10, 64)
if err != nil {
http.Error(w, "invalid target id", http.StatusBadRequest)
return
}
if err := a.store.DeleteSiteTarget(r.Context(), siteID, targetID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Redirect(w, r, "/?msg=target-not-found", http.StatusSeeOther)
return
}
http.Error(w, "failed to delete target", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?msg=target-deleted", http.StatusSeeOther)
}
func (a *app) handleSiteUpdate(w http.ResponseWriter, r *http.Request) { func (a *app) handleSiteUpdate(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUserWithRollingSession(w, r); err != nil { if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)

32
cmd/satoru/mysql_dump.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"fmt"
"satoru/internal/store"
)
func mysqlDumpCommand(target store.SiteTarget, noData bool, outputPath string) string {
base := fmt.Sprintf(
"mysqldump -h %s -u %s -p%s --single-transaction --quick --routines --events %s",
shellQuote(target.MySQLHost.String),
shellQuote(target.MySQLUser.String),
shellQuote(target.MySQLPassword.String),
shellQuote(target.MySQLDB.String),
)
if noData {
return base + " --no-data >/dev/null"
}
return base + " > " + shellQuote(outputPath)
}
func mysqlStatusCommand(target store.SiteTarget) string {
return fmt.Sprintf(
"mysql --version >/dev/null && mysql -h %s -u %s -p%s --connect-timeout=5 -e %s %s >/dev/null",
shellQuote(target.MySQLHost.String),
shellQuote(target.MySQLUser.String),
shellQuote(target.MySQLPassword.String),
shellQuote("SELECT 1"),
shellQuote(target.MySQLDB.String),
)
}

View File

@ -136,6 +136,10 @@ func (a *app) scanSiteNow(ctx context.Context, siteID int64) {
} }
func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) { func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
if target.Mode == "mysql_dump" {
return queryMySQLTargetStatus(ctx, site, target)
}
targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second) cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() defer cancel()
@ -157,11 +161,34 @@ func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarg
return size, nil return size, nil
} }
func queryMySQLTargetStatus(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid {
return 0, errors.New("mysql target missing db host/db user/db name/db password")
}
targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), targetAddr, mysqlStatusCommand(target))
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return 0, errors.New(msg)
}
return 0, nil
}
func remoteSizeCommand(target store.SiteTarget) string { func remoteSizeCommand(target store.SiteTarget) string {
path := shellQuote(target.Path) path := shellQuote(target.Path)
if target.Mode == "sqlite_dump" { if target.Mode == "sqlite_dump" {
return fmt.Sprintf("stat -c%%s -- %s", path) return fmt.Sprintf("stat -c%%s -- %s", path)
} }
if target.Mode == "mysql_dump" {
return ""
}
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path) return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
} }

View File

@ -63,7 +63,11 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
path TEXT NOT NULL, path TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump')), mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump')),
mysql_host TEXT,
mysql_user TEXT,
mysql_db TEXT,
mysql_password TEXT,
last_size_bytes INTEGER, last_size_bytes INTEGER,
last_scan_at DATETIME, last_scan_at DATETIME,
last_error TEXT, last_error TEXT,
@ -193,6 +197,86 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
return nil return nil
}, },
}, },
{
version: 5,
name: "site_targets_mysql_dump",
up: func(ctx context.Context, tx *sql.Tx) error {
exists, err := tableExists(ctx, tx, "site_targets")
if err != nil {
return err
}
if !exists {
return nil
}
if _, err := tx.ExecContext(ctx, `CREATE TABLE site_targets_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
path TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump')),
mysql_host TEXT,
mysql_user TEXT,
mysql_db TEXT,
mysql_password TEXT,
last_size_bytes INTEGER,
last_scan_at DATETIME,
last_error TEXT,
UNIQUE(site_id, path, mode)
)`); err != nil {
return err
}
hasMySQLHost, err := columnExists(ctx, tx, "site_targets", "mysql_host")
if err != nil {
return err
}
if hasMySQLHost {
if _, err := tx.ExecContext(ctx, `
INSERT INTO site_targets_new (id, site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error)
SELECT id, site_id, path, mode, mysql_host, NULL, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error
FROM site_targets`); err != nil {
return err
}
} else {
if _, err := tx.ExecContext(ctx, `
INSERT INTO site_targets_new (id, site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error)
SELECT id, site_id, path, mode, NULL, NULL, NULL, NULL, last_size_bytes, last_scan_at, last_error
FROM site_targets`); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `DROP TABLE site_targets`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `ALTER TABLE site_targets_new RENAME TO site_targets`); err != nil {
return err
}
return nil
},
},
{
version: 6,
name: "site_targets_mysql_user",
up: func(ctx context.Context, tx *sql.Tx) error {
exists, err := tableExists(ctx, tx, "site_targets")
if err != nil {
return err
}
if !exists {
return nil
}
hasUser, err := columnExists(ctx, tx, "site_targets", "mysql_user")
if err != nil {
return err
}
if hasUser {
return nil
}
_, err = tx.ExecContext(ctx, `ALTER TABLE site_targets ADD COLUMN mysql_user TEXT`)
return err
},
},
} }
for _, m := range migrations { for _, m := range migrations {

View File

@ -46,8 +46,13 @@ type Site struct {
} }
type SiteTarget struct { type SiteTarget struct {
ID int64
Path string Path string
Mode string Mode string
MySQLHost sql.NullString
MySQLUser sql.NullString
MySQLDB sql.NullString
MySQLPassword sql.NullString
LastSizeByte sql.NullInt64 LastSizeByte sql.NullInt64
LastScanAt sql.NullTime LastScanAt sql.NullTime
LastError sql.NullString LastError sql.NullString
@ -247,10 +252,14 @@ func (s *Store) CreateSite(ctx context.Context, sshUser, host string, port int,
for _, t := range targets { for _, t := range targets {
if _, err := tx.ExecContext( if _, err := tx.ExecContext(
ctx, ctx,
`INSERT INTO site_targets (site_id, path, mode) VALUES (?, ?, ?)`, `INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, id,
t.Path, t.Path,
t.Mode, t.Mode,
nullStringArg(t.MySQLHost),
nullStringArg(t.MySQLUser),
nullStringArg(t.MySQLDB),
nullStringArg(t.MySQLPassword),
); err != nil { ); err != nil {
return Site{}, err return Site{}, err
} }
@ -293,10 +302,14 @@ func (s *Store) UpdateSite(ctx context.Context, id int64, sshUser, host string,
for _, t := range targets { for _, t := range targets {
if _, err := tx.ExecContext( if _, err := tx.ExecContext(
ctx, ctx,
`INSERT INTO site_targets (site_id, path, mode) VALUES (?, ?, ?)`, `INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, id,
t.Path, t.Path,
t.Mode, t.Mode,
nullStringArg(t.MySQLHost),
nullStringArg(t.MySQLUser),
nullStringArg(t.MySQLDB),
nullStringArg(t.MySQLPassword),
); err != nil { ); err != nil {
return Site{}, err return Site{}, err
} }
@ -333,6 +346,39 @@ func (s *Store) DeleteSite(ctx context.Context, id int64) error {
return nil return nil
} }
func (s *Store) DeleteSiteTarget(ctx context.Context, siteID, targetID int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM site_targets WHERE id = ? AND site_id = ?`, targetID, siteID)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return sql.ErrNoRows
}
s.debugDB("site target deleted", zap.Int64("site_id", siteID), zap.Int64("target_id", targetID))
return nil
}
func (s *Store) AddMySQLDumpTarget(ctx context.Context, siteID int64, dbHost, dbUser, dbName, dbPassword string) error {
_, err := s.db.ExecContext(
ctx,
`INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, 'mysql_dump', ?, ?, ?, ?)`,
siteID,
dbName,
dbHost,
dbUser,
dbName,
dbPassword,
)
if err == nil {
s.debugDB("mysql dump target added", zap.Int64("site_id", siteID), zap.String("db_host", dbHost), zap.String("db_user", dbUser), zap.String("db_name", dbName))
}
return err
}
func (s *Store) ListSites(ctx context.Context) ([]Site, error) { func (s *Store) ListSites(ctx context.Context) ([]Site, error) {
const q = ` const q = `
SELECT id, site_uuid, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes SELECT id, site_uuid, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes
@ -705,7 +751,7 @@ func (s *Store) populateFilters(ctx context.Context, sites []Site) error {
} }
func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget, error) { func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget, error) {
const q = `SELECT site_id, path, mode, last_size_bytes, last_scan_at, last_error FROM site_targets ORDER BY id ASC` const q = `SELECT site_id, id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error FROM site_targets ORDER BY id ASC`
rows, err := s.db.QueryContext(ctx, q) rows, err := s.db.QueryContext(ctx, q)
if err != nil { if err != nil {
return nil, err return nil, err
@ -716,7 +762,7 @@ func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget,
for rows.Next() { for rows.Next() {
var siteID int64 var siteID int64
var target SiteTarget var target SiteTarget
if err := rows.Scan(&siteID, &target.Path, &target.Mode, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil { if err := rows.Scan(&siteID, &target.ID, &target.Path, &target.Mode, &target.MySQLHost, &target.MySQLUser, &target.MySQLDB, &target.MySQLPassword, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil {
return nil, err return nil, err
} }
out[siteID] = append(out[siteID], target) out[siteID] = append(out[siteID], target)
@ -728,7 +774,7 @@ func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget,
} }
func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget, error) { func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget, error) {
const q = `SELECT path, mode, last_size_bytes, last_scan_at, last_error FROM site_targets WHERE site_id = ? ORDER BY id ASC` const q = `SELECT id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error FROM site_targets WHERE site_id = ? ORDER BY id ASC`
rows, err := s.db.QueryContext(ctx, q, siteID) rows, err := s.db.QueryContext(ctx, q, siteID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -738,7 +784,7 @@ func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget
var out []SiteTarget var out []SiteTarget
for rows.Next() { for rows.Next() {
var target SiteTarget var target SiteTarget
if err := rows.Scan(&target.Path, &target.Mode, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil { if err := rows.Scan(&target.ID, &target.Path, &target.Mode, &target.MySQLHost, &target.MySQLUser, &target.MySQLDB, &target.MySQLPassword, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil {
return nil, err return nil, err
} }
out = append(out, target) out = append(out, target)

View File

@ -99,14 +99,26 @@ func Dashboard(data DashboardData) templ.Component {
sizeOrErr := "size pending" sizeOrErr := "size pending"
if t.LastError.Valid && t.LastError.String != "" { if t.LastError.Valid && t.LastError.String != "" {
sizeOrErr = "error: " + t.LastError.String sizeOrErr = "error: " + t.LastError.String
} else if t.Mode == "mysql_dump" && t.LastScanAt.Valid {
sizeOrErr = "connection established"
} else if t.LastSizeByte.Valid { } else if t.LastSizeByte.Valid {
sizeOrErr = formatBytes(t.LastSizeByte.Int64) sizeOrErr = formatBytes(t.LastSizeByte.Int64)
} }
targets.WriteString(fmt.Sprintf(`<li><span class="pill %s">%s</span> <code>%s</code><span class="muted inline">%s</span></li>`, label := targetModeLabel(t.Mode)
pathText := t.Path
if t.Mode == "mysql_dump" {
label = "mysql dump"
if t.MySQLHost.Valid && t.MySQLUser.Valid && t.MySQLDB.Valid {
pathText = fmt.Sprintf("db=%s user=%s host=%s", t.MySQLDB.String, t.MySQLUser.String, t.MySQLHost.String)
}
}
targets.WriteString(fmt.Sprintf(`<li><span class="pill %s">%s</span> <code>%s</code><span class="muted inline">%s</span><form class="inline-form" method="post" action="/sites/%d/targets/%d/delete"><button class="button ghost button-sm" type="submit">Remove</button></form></li>`,
targetModeClass(t.Mode), targetModeClass(t.Mode),
html.EscapeString(targetModeLabel(t.Mode)), html.EscapeString(label),
html.EscapeString(t.Path), html.EscapeString(pathText),
html.EscapeString(sizeOrErr))) html.EscapeString(sizeOrErr),
site.ID,
t.ID))
} }
sites.WriteString(fmt.Sprintf(` sites.WriteString(fmt.Sprintf(`
@ -140,6 +152,16 @@ func Dashboard(data DashboardData) templ.Component {
<button class="button" type="submit">Save changes</button> <button class="button" type="submit">Save changes</button>
</form> </form>
</details> </details>
<details class="edit-panel">
<summary>Add MySQL dump operation</summary>
<form class="grid-2" method="post" action="/sites/%d/mysql-dumps">
<label class="stack"><span>DB Host</span><input name="db_host" placeholder="127.0.0.1" required /></label>
<label class="stack"><span>DB User</span><input name="db_user" placeholder="backup_user" required /></label>
<label class="stack"><span>Database</span><input name="db_name" placeholder="appdb" required /></label>
<label class="stack"><span>DB Password</span><input type="password" name="db_password" required /></label>
<button class="button" type="submit">Add MySQL dump</button>
</form>
</details>
<p class="muted">Backup targets:</p> <p class="muted">Backup targets:</p>
<ul class="target-list">%s</ul> <ul class="target-list">%s</ul>
<p class="muted">Filters: %s</p> <p class="muted">Filters: %s</p>
@ -168,6 +190,7 @@ func Dashboard(data DashboardData) templ.Component {
html.EscapeString(joinTargetPaths(site.Targets, "directory")), html.EscapeString(joinTargetPaths(site.Targets, "directory")),
html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")), html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")),
html.EscapeString(strings.Join(site.Filters, "\n")), html.EscapeString(strings.Join(site.Filters, "\n")),
site.ID,
targets.String(), targets.String(),
html.EscapeString(formatFilters(site.Filters)), html.EscapeString(formatFilters(site.Filters)),
html.EscapeString(scanState), html.EscapeString(scanState),
@ -458,6 +481,14 @@ func formatFlash(code string) string {
return "Port must be an integer between 1 and 65535." return "Port must be an integer between 1 and 65535."
case "password-updated": case "password-updated":
return "Password updated." return "Password updated."
case "mysql-added":
return "MySQL dump operation added."
case "mysql-invalid":
return "MySQL host, user, database, and password are required."
case "target-deleted":
return "Target removed."
case "target-not-found":
return "Target not found."
default: default:
return code return code
} }
@ -467,6 +498,9 @@ func targetModeLabel(mode string) string {
if mode == "sqlite_dump" { if mode == "sqlite_dump" {
return "sqlite dump" return "sqlite dump"
} }
if mode == "mysql_dump" {
return "mysql dump"
}
return "directory" return "directory"
} }
@ -488,7 +522,7 @@ func formatFilters(filters []string) string {
} }
func targetModeClass(mode string) string { func targetModeClass(mode string) string {
if mode == "sqlite_dump" { if mode == "sqlite_dump" || mode == "mysql_dump" {
return "sqlite" return "sqlite"
} }
return "ok" return "ok"

View File

@ -302,6 +302,11 @@ textarea {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.inline-form {
display: inline-block;
margin-left: 0.5rem;
}
.throbber { .throbber {
display: inline-block; display: inline-block;
width: 0.9rem; width: 0.9rem;