backup jobs work now with sql dumps too
This commit is contained in:
parent
e5225a1353
commit
2ce2d5d881
|
|
@ -33,3 +33,4 @@ data/
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
backups/
|
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)
|
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[:])
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,16 @@ type Site struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteTarget struct {
|
type SiteTarget struct {
|
||||||
Path string
|
ID int64
|
||||||
Mode string
|
Path string
|
||||||
LastSizeByte sql.NullInt64
|
Mode string
|
||||||
LastScanAt sql.NullTime
|
MySQLHost sql.NullString
|
||||||
LastError sql.NullString
|
MySQLUser sql.NullString
|
||||||
|
MySQLDB sql.NullString
|
||||||
|
MySQLPassword sql.NullString
|
||||||
|
LastSizeByte sql.NullInt64
|
||||||
|
LastScanAt sql.NullTime
|
||||||
|
LastError sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue