backup jobs work now with sql dumps too
This commit is contained in:
parent
e5225a1353
commit
2ce2d5d881
|
|
@ -33,3 +33,4 @@ data/
|
|||
.env.*
|
||||
!.env.example
|
||||
backups/
|
||||
repos/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
case "sqlite_dump":
|
||||
err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir)
|
||||
case "mysql_dump":
|
||||
err = pullMySQLTarget(ctx, site, target, stageDir)
|
||||
default:
|
||||
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 {
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -173,6 +178,44 @@ func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.S
|
|||
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 {
|
||||
tmpBase := fmt.Sprintf("/tmp/satoru-backup-%d-%s.sqlite3", jobID, shortHash(target.Path))
|
||||
quotedDB := shellQuote(target.Path)
|
||||
|
|
@ -201,6 +244,36 @@ func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target
|
|||
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 {
|
||||
if err := ensureResticRepo(ctx, repoPath); err != nil {
|
||||
return err
|
||||
|
|
@ -373,14 +446,30 @@ func latestSnapshotIDForSite(ctx context.Context, repoPath, siteUUID string) (st
|
|||
}
|
||||
|
||||
func targetStageDir(root, siteUUID string, target store.SiteTarget) string {
|
||||
hash := hashPath(target.Path)
|
||||
hash := hashPath(targetIdentity(target))
|
||||
modeDir := "dir"
|
||||
if target.Mode == "sqlite_dump" {
|
||||
modeDir = "sqlite"
|
||||
} else if target.Mode == "mysql_dump" {
|
||||
modeDir = "mysql"
|
||||
}
|
||||
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 {
|
||||
sum := sha256.Sum256([]byte(path))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
|
|
|||
|
|
@ -182,9 +182,11 @@ func (a *app) runPreflightJob(ctx context.Context, job store.Job, site store.Sit
|
|||
var err error
|
||||
switch t.Mode {
|
||||
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":
|
||||
err = sqlitePreflightCheck(ctx, site, t.Path)
|
||||
case "mysql_dump":
|
||||
err = mysqlPreflightCheck(ctx, site, t)
|
||||
default:
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
job, err := a.store.CreateJob(ctx, siteID, jobTypePreflight)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -80,9 +80,11 @@ func main() {
|
|||
r.Post("/account/password", a.handlePasswordSubmit)
|
||||
r.Post("/sites", a.handleSiteCreate)
|
||||
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}/restart", a.handleSiteRestart)
|
||||
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}/delete", a.handleSiteDelete)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
if target.Mode == "mysql_dump" {
|
||||
return queryMySQLTargetStatus(ctx, site, target)
|
||||
}
|
||||
|
||||
targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
|
@ -157,11 +161,34 @@ func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarg
|
|||
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 {
|
||||
path := shellQuote(target.Path)
|
||||
if target.Mode == "sqlite_dump" {
|
||||
return fmt.Sprintf("stat -c%%s -- %s", path)
|
||||
}
|
||||
if target.Mode == "mysql_dump" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|||
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')),
|
||||
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,
|
||||
|
|
@ -193,6 +197,86 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -46,8 +46,13 @@ type Site struct {
|
|||
}
|
||||
|
||||
type SiteTarget struct {
|
||||
ID int64
|
||||
Path string
|
||||
Mode string
|
||||
MySQLHost sql.NullString
|
||||
MySQLUser sql.NullString
|
||||
MySQLDB sql.NullString
|
||||
MySQLPassword sql.NullString
|
||||
LastSizeByte sql.NullInt64
|
||||
LastScanAt sql.NullTime
|
||||
LastError sql.NullString
|
||||
|
|
@ -247,10 +252,14 @@ func (s *Store) CreateSite(ctx context.Context, sshUser, host string, port int,
|
|||
for _, t := range targets {
|
||||
if _, err := tx.ExecContext(
|
||||
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,
|
||||
t.Path,
|
||||
t.Mode,
|
||||
nullStringArg(t.MySQLHost),
|
||||
nullStringArg(t.MySQLUser),
|
||||
nullStringArg(t.MySQLDB),
|
||||
nullStringArg(t.MySQLPassword),
|
||||
); err != nil {
|
||||
return Site{}, err
|
||||
}
|
||||
|
|
@ -293,10 +302,14 @@ func (s *Store) UpdateSite(ctx context.Context, id int64, sshUser, host string,
|
|||
for _, t := range targets {
|
||||
if _, err := tx.ExecContext(
|
||||
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,
|
||||
t.Path,
|
||||
t.Mode,
|
||||
nullStringArg(t.MySQLHost),
|
||||
nullStringArg(t.MySQLUser),
|
||||
nullStringArg(t.MySQLDB),
|
||||
nullStringArg(t.MySQLPassword),
|
||||
); err != nil {
|
||||
return Site{}, err
|
||||
}
|
||||
|
|
@ -333,6 +346,39 @@ func (s *Store) DeleteSite(ctx context.Context, id int64) error {
|
|||
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) {
|
||||
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
|
||||
|
|
@ -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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -716,7 +762,7 @@ func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget,
|
|||
for rows.Next() {
|
||||
var siteID int64
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -738,7 +784,7 @@ func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget
|
|||
var out []SiteTarget
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
out = append(out, target)
|
||||
|
|
|
|||
|
|
@ -99,14 +99,26 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
sizeOrErr := "size pending"
|
||||
if t.LastError.Valid && 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 {
|
||||
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),
|
||||
html.EscapeString(targetModeLabel(t.Mode)),
|
||||
html.EscapeString(t.Path),
|
||||
html.EscapeString(sizeOrErr)))
|
||||
html.EscapeString(label),
|
||||
html.EscapeString(pathText),
|
||||
html.EscapeString(sizeOrErr),
|
||||
site.ID,
|
||||
t.ID))
|
||||
}
|
||||
|
||||
sites.WriteString(fmt.Sprintf(`
|
||||
|
|
@ -140,6 +152,16 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
<button class="button" type="submit">Save changes</button>
|
||||
</form>
|
||||
</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>
|
||||
<ul class="target-list">%s</ul>
|
||||
<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, "sqlite_dump")),
|
||||
html.EscapeString(strings.Join(site.Filters, "\n")),
|
||||
site.ID,
|
||||
targets.String(),
|
||||
html.EscapeString(formatFilters(site.Filters)),
|
||||
html.EscapeString(scanState),
|
||||
|
|
@ -458,6 +481,14 @@ func formatFlash(code string) string {
|
|||
return "Port must be an integer between 1 and 65535."
|
||||
case "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:
|
||||
return code
|
||||
}
|
||||
|
|
@ -467,6 +498,9 @@ func targetModeLabel(mode string) string {
|
|||
if mode == "sqlite_dump" {
|
||||
return "sqlite dump"
|
||||
}
|
||||
if mode == "mysql_dump" {
|
||||
return "mysql dump"
|
||||
}
|
||||
return "directory"
|
||||
}
|
||||
|
||||
|
|
@ -488,7 +522,7 @@ func formatFilters(filters []string) string {
|
|||
}
|
||||
|
||||
func targetModeClass(mode string) string {
|
||||
if mode == "sqlite_dump" {
|
||||
if mode == "sqlite_dump" || mode == "mysql_dump" {
|
||||
return "sqlite"
|
||||
}
|
||||
return "ok"
|
||||
|
|
|
|||
|
|
@ -302,6 +302,11 @@ textarea {
|
|||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.throbber {
|
||||
display: inline-block;
|
||||
width: 0.9rem;
|
||||
|
|
|
|||
Loading…
Reference in New Issue