saving
This commit is contained in:
parent
848b328359
commit
cd245e4913
|
|
@ -69,6 +69,10 @@ func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site)
|
||||||
err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir)
|
err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir)
|
||||||
case "mysql_dump":
|
case "mysql_dump":
|
||||||
err = pullMySQLTarget(ctx, site, target, stageDir)
|
err = pullMySQLTarget(ctx, site, target, stageDir)
|
||||||
|
case "podman_save":
|
||||||
|
err = pullPodmanSaveTarget(ctx, site, target, stageDir)
|
||||||
|
case "podman_export":
|
||||||
|
err = pullPodmanExportTarget(ctx, site, target, stageDir)
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unknown target mode: %s", target.Mode)
|
err = fmt.Errorf("unknown target mode: %s", target.Mode)
|
||||||
}
|
}
|
||||||
|
|
@ -273,6 +277,62 @@ func pullMySQLTarget(ctx context.Context, site store.Site, target store.SiteTarg
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pullPodmanSaveTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
|
||||||
|
imageName := strings.TrimSpace(target.Path)
|
||||||
|
if imageName == "" {
|
||||||
|
return errors.New("podman target missing image name")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpTar := fmt.Sprintf("/tmp/satoru-podman-%s.tar", shortHash(targetIdentity(target)))
|
||||||
|
remoteCmd := fmt.Sprintf("podman save -o %s %s", shellQuote(tmpTar), shellQuote(imageName))
|
||||||
|
if err := sshCheck(ctx, site, remoteCmd); err != nil {
|
||||||
|
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
|
||||||
|
remoteTar := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpTar)
|
||||||
|
localFile := filepath.Join(stageDir, "podman-image.tar")
|
||||||
|
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteTar, localFile)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullPodmanExportTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
|
||||||
|
containerName := strings.TrimSpace(target.Path)
|
||||||
|
if containerName == "" {
|
||||||
|
return errors.New("podman export target missing container name")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpTar := fmt.Sprintf("/tmp/satoru-podman-export-%s.tar", shortHash(targetIdentity(target)))
|
||||||
|
remoteCmd := fmt.Sprintf("podman export -o %s %s", shellQuote(tmpTar), shellQuote(containerName))
|
||||||
|
if err := sshCheck(ctx, site, remoteCmd); err != nil {
|
||||||
|
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
|
||||||
|
remoteTar := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpTar)
|
||||||
|
localFile := filepath.Join(stageDir, "podman-container.tar")
|
||||||
|
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteTar, localFile)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
|
||||||
|
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
|
||||||
|
|
@ -345,13 +405,17 @@ func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Si
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "restic sync started"})
|
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "restic sync started"})
|
||||||
args := []string{"-r", repoPath, "copy", snapshotID, "--repo2", b2Repo}
|
args, env, copyMode, err := buildResticCopyInvocation(ctx, repoPath, b2Repo, snapshotID)
|
||||||
a.log.Debug("restic sync copy", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("local_repo", repoPath), zap.String("b2_repo", b2Repo), zap.String("snapshot_id", snapshotID), zap.Strings("args", args))
|
if err != nil {
|
||||||
|
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic sync failed: " + err.Error()})
|
||||||
|
return "failed", "restic sync failed: command build error"
|
||||||
|
}
|
||||||
|
a.log.Debug("restic sync copy", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("local_repo", repoPath), zap.String("b2_repo", b2Repo), zap.String("snapshot_id", snapshotID), zap.String("copy_mode", copyMode), zap.Strings("args", args))
|
||||||
|
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(cmdCtx, "restic", args...)
|
cmd := exec.CommandContext(cmdCtx, "restic", args...)
|
||||||
cmd.Env = resticEnv()
|
cmd.Env = env
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := strings.TrimSpace(string(out))
|
msg := strings.TrimSpace(string(out))
|
||||||
|
|
@ -366,6 +430,80 @@ func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Si
|
||||||
return "success", "restic sync completed"
|
return "success", "restic sync completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildResticCopyInvocation(ctx context.Context, sourceRepo, destinationRepo, snapshotID string) ([]string, []string, string, error) {
|
||||||
|
modern, err := resticSupportsFromRepo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
if modern {
|
||||||
|
args := []string{"-r", destinationRepo, "copy", snapshotID, "--from-repo", sourceRepo}
|
||||||
|
return args, resticCopyEnvModern(), "from-repo", nil
|
||||||
|
}
|
||||||
|
args := []string{"-r", sourceRepo, "copy", snapshotID, "--repo2", destinationRepo}
|
||||||
|
return args, resticEnv(), "repo2", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resticSupportsFromRepo(ctx context.Context) (bool, error) {
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(cmdCtx, "restic", "copy", "--help")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(string(out))
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return false, errors.New(msg)
|
||||||
|
}
|
||||||
|
help := string(out)
|
||||||
|
return strings.Contains(help, "--from-repo"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resticCopyEnvModern() []string {
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
password := configValue("RESTIC_PASSWORD")
|
||||||
|
passwordFile := configValue("RESTIC_PASSWORD_FILE")
|
||||||
|
password2 := configValue("RESTIC_PASSWORD2")
|
||||||
|
passwordFile2 := configValue("RESTIC_PASSWORD_FILE2")
|
||||||
|
b2ApplicationID := configValue("B2_APPLICATION_ID")
|
||||||
|
b2ApplicationKey := configValue("B2_APPLICATION_KEY")
|
||||||
|
b2AccountID := configValue("B2_ACCOUNT_ID")
|
||||||
|
b2AccountKey := configValue("B2_ACCOUNT_KEY")
|
||||||
|
|
||||||
|
if password == "" && passwordFile == "" {
|
||||||
|
password = configuredResticPassword()
|
||||||
|
}
|
||||||
|
if password2 == "" && passwordFile2 == "" {
|
||||||
|
if password != "" {
|
||||||
|
password2 = password
|
||||||
|
} else {
|
||||||
|
password2 = configuredResticPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if password2 != "" {
|
||||||
|
env = append(env, "RESTIC_PASSWORD="+password2)
|
||||||
|
}
|
||||||
|
if passwordFile2 != "" {
|
||||||
|
env = append(env, "RESTIC_PASSWORD_FILE="+passwordFile2)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
env = append(env, "RESTIC_FROM_PASSWORD="+password)
|
||||||
|
}
|
||||||
|
if passwordFile != "" {
|
||||||
|
env = append(env, "RESTIC_FROM_PASSWORD_FILE="+passwordFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if b2AccountID == "" && b2ApplicationID != "" {
|
||||||
|
env = append(env, "B2_ACCOUNT_ID="+b2ApplicationID)
|
||||||
|
}
|
||||||
|
if b2AccountKey == "" && b2ApplicationKey != "" {
|
||||||
|
env = append(env, "B2_ACCOUNT_KEY="+b2ApplicationKey)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
func ensureResticRepo(ctx context.Context, repoPath string) error {
|
func ensureResticRepo(ctx context.Context, repoPath string) error {
|
||||||
check := exec.CommandContext(ctx, "restic", "-r", repoPath, "cat", "config")
|
check := exec.CommandContext(ctx, "restic", "-r", repoPath, "cat", "config")
|
||||||
check.Env = resticEnv()
|
check.Env = resticEnv()
|
||||||
|
|
@ -470,6 +608,10 @@ func targetStageDir(root, siteUUID string, target store.SiteTarget) string {
|
||||||
modeDir = "sqlite"
|
modeDir = "sqlite"
|
||||||
} else if target.Mode == "mysql_dump" {
|
} else if target.Mode == "mysql_dump" {
|
||||||
modeDir = "mysql"
|
modeDir = "mysql"
|
||||||
|
} else if target.Mode == "podman_save" {
|
||||||
|
modeDir = "podman"
|
||||||
|
} else if target.Mode == "podman_export" {
|
||||||
|
modeDir = "podman-export"
|
||||||
}
|
}
|
||||||
return filepath.Join(root, siteUUID, hash, modeDir)
|
return filepath.Join(root, siteUUID, hash, modeDir)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,10 @@ func (a *app) runPreflightJob(ctx context.Context, job store.Job, site store.Sit
|
||||||
err = sqlitePreflightCheck(ctx, site, t.Path)
|
err = sqlitePreflightCheck(ctx, site, t.Path)
|
||||||
case "mysql_dump":
|
case "mysql_dump":
|
||||||
err = mysqlPreflightCheck(ctx, site, t)
|
err = mysqlPreflightCheck(ctx, site, t)
|
||||||
|
case "podman_save":
|
||||||
|
err = podmanSavePreflightCheck(ctx, site, t)
|
||||||
|
case "podman_export":
|
||||||
|
err = podmanExportPreflightCheck(ctx, site, t)
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unknown target mode: %s", t.Mode)
|
err = fmt.Errorf("unknown target mode: %s", t.Mode)
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +259,24 @@ func mysqlPreflightCheck(ctx context.Context, site store.Site, target store.Site
|
||||||
return sshCheck(ctx, site, cmd)
|
return sshCheck(ctx, site, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func podmanSavePreflightCheck(ctx context.Context, site store.Site, target store.SiteTarget) error {
|
||||||
|
imageName := strings.TrimSpace(target.Path)
|
||||||
|
if imageName == "" {
|
||||||
|
return errors.New("podman target missing image name")
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("podman --version >/dev/null && podman image exists %s", shellQuote(imageName))
|
||||||
|
return sshCheck(ctx, site, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func podmanExportPreflightCheck(ctx context.Context, site store.Site, target store.SiteTarget) error {
|
||||||
|
containerName := strings.TrimSpace(target.Path)
|
||||||
|
if containerName == "" {
|
||||||
|
return errors.New("podman export target missing container name")
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("podman --version >/dev/null && podman container exists %s", shellQuote(containerName))
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ func main() {
|
||||||
r.Post("/sites/{id}/run", a.handleSiteRun)
|
r.Post("/sites/{id}/run", a.handleSiteRun)
|
||||||
r.Post("/sites/{id}/sync", a.handleSiteSyncNow)
|
r.Post("/sites/{id}/sync", a.handleSiteSyncNow)
|
||||||
r.Post("/sites/{id}/mysql-dumps", a.handleSiteAddMySQLDump)
|
r.Post("/sites/{id}/mysql-dumps", a.handleSiteAddMySQLDump)
|
||||||
|
r.Post("/sites/{id}/podman-saves", a.handleSiteAddPodmanSave)
|
||||||
|
r.Post("/sites/{id}/podman-exports", a.handleSiteAddPodmanExport)
|
||||||
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)
|
||||||
|
|
@ -462,6 +464,78 @@ func (a *app) handleSiteAddMySQLDump(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/?msg=mysql-added", http.StatusSeeOther)
|
http.Redirect(w, r, "/?msg=mysql-added", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSiteAddPodmanSave(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
|
||||||
|
}
|
||||||
|
|
||||||
|
imageName := strings.TrimSpace(r.FormValue("image_name"))
|
||||||
|
if imageName == "" {
|
||||||
|
http.Redirect(w, r, "/?msg=podman-invalid", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.store.AddPodmanSaveTarget(r.Context(), siteID, imageName); err != nil {
|
||||||
|
http.Error(w, "failed to add podman save target", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?msg=podman-added", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSiteAddPodmanExport(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
|
||||||
|
}
|
||||||
|
|
||||||
|
containerName := strings.TrimSpace(r.FormValue("container_name"))
|
||||||
|
if containerName == "" {
|
||||||
|
http.Redirect(w, r, "/?msg=podman-export-invalid", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.store.AddPodmanExportTarget(r.Context(), siteID, containerName); err != nil {
|
||||||
|
http.Error(w, "failed to add podman export target", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?msg=podman-export-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)
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,12 @@ func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarg
|
||||||
if target.Mode == "mysql_dump" {
|
if target.Mode == "mysql_dump" {
|
||||||
return queryMySQLTargetStatus(ctx, site, target)
|
return queryMySQLTargetStatus(ctx, site, target)
|
||||||
}
|
}
|
||||||
|
if target.Mode == "podman_save" {
|
||||||
|
return queryPodmanTargetStatus(ctx, site, target)
|
||||||
|
}
|
||||||
|
if target.Mode == "podman_export" {
|
||||||
|
return queryPodmanExportTargetStatus(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)
|
||||||
|
|
@ -268,6 +274,48 @@ func queryMySQLTargetStatus(ctx context.Context, site store.Site, target store.S
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryPodmanTargetStatus(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
|
||||||
|
imageName := strings.TrimSpace(target.Path)
|
||||||
|
if imageName == "" {
|
||||||
|
return 0, errors.New("podman target missing image name")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, fmt.Sprintf("podman --version >/dev/null && podman image exists %s", shellQuote(imageName)))
|
||||||
|
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 queryPodmanExportTargetStatus(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
|
||||||
|
containerName := strings.TrimSpace(target.Path)
|
||||||
|
if containerName == "" {
|
||||||
|
return 0, errors.New("podman export target missing container name")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, fmt.Sprintf("podman --version >/dev/null && podman container exists %s", shellQuote(containerName)))
|
||||||
|
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" {
|
||||||
|
|
@ -276,6 +324,12 @@ func remoteSizeCommand(target store.SiteTarget) string {
|
||||||
if target.Mode == "mysql_dump" {
|
if target.Mode == "mysql_dump" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if target.Mode == "podman_save" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if target.Mode == "podman_export" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
|
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ 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', 'mysql_dump')),
|
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump', 'podman_save', 'podman_export')),
|
||||||
mysql_host TEXT,
|
mysql_host TEXT,
|
||||||
mysql_user TEXT,
|
mysql_user TEXT,
|
||||||
mysql_db TEXT,
|
mysql_db TEXT,
|
||||||
|
|
@ -213,7 +213,7 @@ 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', 'mysql_dump')),
|
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump', 'podman_save', 'podman_export')),
|
||||||
mysql_host TEXT,
|
mysql_host TEXT,
|
||||||
mysql_user TEXT,
|
mysql_user TEXT,
|
||||||
mysql_db TEXT,
|
mysql_db TEXT,
|
||||||
|
|
@ -277,6 +277,96 @@ FROM site_targets`); err != nil {
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 7,
|
||||||
|
name: "site_targets_podman_save_mode",
|
||||||
|
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', 'podman_save', 'podman_export')),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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, mysql_user, mysql_db, mysql_password, 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: 8,
|
||||||
|
name: "site_targets_podman_export_mode",
|
||||||
|
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', 'podman_save', 'podman_export')),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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, mysql_user, mysql_db, mysql_password, 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
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,32 @@ func (s *Store) AddMySQLDumpTarget(ctx context.Context, siteID int64, dbHost, db
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddPodmanSaveTarget(ctx context.Context, siteID int64, imageName string) error {
|
||||||
|
_, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, 'podman_save', NULL, NULL, NULL, NULL)`,
|
||||||
|
siteID,
|
||||||
|
imageName,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
s.debugDB("podman save target added", zap.Int64("site_id", siteID), zap.String("image_name", imageName))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddPodmanExportTarget(ctx context.Context, siteID int64, containerName string) error {
|
||||||
|
_, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, 'podman_export', NULL, NULL, NULL, NULL)`,
|
||||||
|
siteID,
|
||||||
|
containerName,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
s.debugDB("podman export target added", zap.Int64("site_id", siteID), zap.String("container_name", containerName))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ 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 {
|
} else if (t.Mode == "mysql_dump" || t.Mode == "podman_save" || t.Mode == "podman_export") && t.LastScanAt.Valid {
|
||||||
sizeOrErr = "connection established"
|
sizeOrErr = "connection established"
|
||||||
} else if t.LastSizeByte.Valid {
|
} else if t.LastSizeByte.Valid {
|
||||||
sizeOrErr = formatBytes(t.LastSizeByte.Int64)
|
sizeOrErr = formatBytes(t.LastSizeByte.Int64)
|
||||||
|
|
@ -129,6 +129,12 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
if t.MySQLHost.Valid && t.MySQLUser.Valid && t.MySQLDB.Valid {
|
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)
|
pathText = fmt.Sprintf("db=%s user=%s host=%s", t.MySQLDB.String, t.MySQLUser.String, t.MySQLHost.String)
|
||||||
}
|
}
|
||||||
|
} else if t.Mode == "podman_save" {
|
||||||
|
label = "podman save"
|
||||||
|
pathText = "image=" + t.Path
|
||||||
|
} else if t.Mode == "podman_export" {
|
||||||
|
label = "podman export"
|
||||||
|
pathText = "container=" + t.Path
|
||||||
}
|
}
|
||||||
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>`,
|
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),
|
||||||
|
|
@ -183,6 +189,20 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
<button class="button" type="submit">Add MySQL dump</button>
|
<button class="button" type="submit">Add MySQL dump</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
<details class="edit-panel">
|
||||||
|
<summary>Add Podman save operation</summary>
|
||||||
|
<form class="grid-2" method="post" action="/sites/%d/podman-saves">
|
||||||
|
<label class="stack"><span>Image Name</span><input name="image_name" placeholder="myimage:latest" required /></label>
|
||||||
|
<button class="button" type="submit">Add Podman save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<details class="edit-panel">
|
||||||
|
<summary>Add Podman export operation</summary>
|
||||||
|
<form class="grid-2" method="post" action="/sites/%d/podman-exports">
|
||||||
|
<label class="stack"><span>Container Name</span><input name="container_name" placeholder="forgejo" required /></label>
|
||||||
|
<button class="button" type="submit">Add Podman export</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>
|
||||||
|
|
@ -213,6 +233,8 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
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,
|
site.ID,
|
||||||
|
site.ID,
|
||||||
|
site.ID,
|
||||||
targets.String(),
|
targets.String(),
|
||||||
html.EscapeString(formatFilters(site.Filters)),
|
html.EscapeString(formatFilters(site.Filters)),
|
||||||
html.EscapeString(scanState),
|
html.EscapeString(scanState),
|
||||||
|
|
@ -590,6 +612,14 @@ func formatFlash(code string) string {
|
||||||
return "MySQL dump operation added."
|
return "MySQL dump operation added."
|
||||||
case "mysql-invalid":
|
case "mysql-invalid":
|
||||||
return "MySQL host, user, database, and password are required."
|
return "MySQL host, user, database, and password are required."
|
||||||
|
case "podman-added":
|
||||||
|
return "Podman save operation added."
|
||||||
|
case "podman-invalid":
|
||||||
|
return "Podman image name is required."
|
||||||
|
case "podman-export-added":
|
||||||
|
return "Podman export operation added."
|
||||||
|
case "podman-export-invalid":
|
||||||
|
return "Podman container name is required."
|
||||||
case "target-deleted":
|
case "target-deleted":
|
||||||
return "Target removed."
|
return "Target removed."
|
||||||
case "target-not-found":
|
case "target-not-found":
|
||||||
|
|
@ -610,6 +640,12 @@ func targetModeLabel(mode string) string {
|
||||||
if mode == "mysql_dump" {
|
if mode == "mysql_dump" {
|
||||||
return "mysql dump"
|
return "mysql dump"
|
||||||
}
|
}
|
||||||
|
if mode == "podman_save" {
|
||||||
|
return "podman save"
|
||||||
|
}
|
||||||
|
if mode == "podman_export" {
|
||||||
|
return "podman export"
|
||||||
|
}
|
||||||
return "directory"
|
return "directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -631,7 +667,7 @@ func formatFilters(filters []string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func targetModeClass(mode string) string {
|
func targetModeClass(mode string) string {
|
||||||
if mode == "sqlite_dump" || mode == "mysql_dump" {
|
if mode == "sqlite_dump" || mode == "mysql_dump" || mode == "podman_save" || mode == "podman_export" {
|
||||||
return "sqlite"
|
return "sqlite"
|
||||||
}
|
}
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue