diff --git a/cmd/satoru/backup_job.go b/cmd/satoru/backup_job.go index be95e68..29f57da 100644 --- a/cmd/satoru/backup_job.go +++ b/cmd/satoru/backup_job.go @@ -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) case "mysql_dump": 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: 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 } +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 { if err := ensureResticRepo(ctx, repoPath); err != nil { 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"}) - args := []string{"-r", repoPath, "copy", snapshotID, "--repo2", b2Repo} - 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)) + args, env, copyMode, err := buildResticCopyInvocation(ctx, repoPath, b2Repo, snapshotID) + 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) defer cancel() cmd := exec.CommandContext(cmdCtx, "restic", args...) - cmd.Env = resticEnv() + cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { 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" } +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 { check := exec.CommandContext(ctx, "restic", "-r", repoPath, "cat", "config") check.Env = resticEnv() @@ -470,6 +608,10 @@ func targetStageDir(root, siteUUID string, target store.SiteTarget) string { modeDir = "sqlite" } else if target.Mode == "mysql_dump" { 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) } diff --git a/cmd/satoru/jobs.go b/cmd/satoru/jobs.go index f6311b9..c344960 100644 --- a/cmd/satoru/jobs.go +++ b/cmd/satoru/jobs.go @@ -189,6 +189,10 @@ func (a *app) runPreflightJob(ctx context.Context, job store.Job, site store.Sit err = sqlitePreflightCheck(ctx, site, t.Path) case "mysql_dump": err = mysqlPreflightCheck(ctx, site, t) + case "podman_save": + err = podmanSavePreflightCheck(ctx, site, t) + case "podman_export": + err = podmanExportPreflightCheck(ctx, site, t) default: 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) } +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) { job, err := a.store.CreateJob(ctx, siteID, jobTypePreflight) if err != nil { diff --git a/cmd/satoru/main.go b/cmd/satoru/main.go index f424c75..e4d8d81 100644 --- a/cmd/satoru/main.go +++ b/cmd/satoru/main.go @@ -101,6 +101,8 @@ func main() { r.Post("/sites/{id}/run", a.handleSiteRun) r.Post("/sites/{id}/sync", a.handleSiteSyncNow) 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}/restart", a.handleSiteRestart) 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) } +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) { if _, err := a.currentUserWithRollingSession(w, r); err != nil { http.Redirect(w, r, "/signin", http.StatusSeeOther) diff --git a/cmd/satoru/scanner.go b/cmd/satoru/scanner.go index 4e77cc4..fb3ee57 100644 --- a/cmd/satoru/scanner.go +++ b/cmd/satoru/scanner.go @@ -226,6 +226,12 @@ func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarg if target.Mode == "mysql_dump" { 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) 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 } +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 { path := shellQuote(target.Path) if target.Mode == "sqlite_dump" { @@ -276,6 +324,12 @@ func remoteSizeCommand(target store.SiteTarget) string { if target.Mode == "mysql_dump" { return "" } + if target.Mode == "podman_save" { + return "" + } + if target.Mode == "podman_export" { + return "" + } return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path) } diff --git a/internal/store/migrations.go b/internal/store/migrations.go index ec6a4b3..78a2010 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -63,7 +63,7 @@ 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', 'mysql_dump')), + 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, @@ -213,7 +213,7 @@ 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', 'mysql_dump')), + 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, @@ -277,6 +277,96 @@ FROM site_targets`); err != nil { 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 { diff --git a/internal/store/store.go b/internal/store/store.go index 3d39467..107c5d5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -379,6 +379,32 @@ func (s *Store) AddMySQLDumpTarget(ctx context.Context, siteID int64, dbHost, db 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) { 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 diff --git a/internal/webui/dashboard.go b/internal/webui/dashboard.go index f658e03..27cd8c1 100644 --- a/internal/webui/dashboard.go +++ b/internal/webui/dashboard.go @@ -117,7 +117,7 @@ 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 { + } else if (t.Mode == "mysql_dump" || t.Mode == "podman_save" || t.Mode == "podman_export") && t.LastScanAt.Valid { sizeOrErr = "connection established" } else if t.LastSizeByte.Valid { 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 { 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(`
  • %s %s%s
  • `, targetModeClass(t.Mode), @@ -183,6 +189,20 @@ func Dashboard(data DashboardData) templ.Component { +
    + Add Podman save operation +
    + + +
    +
    +
    + Add Podman export operation +
    + + +
    +

    Backup targets:

    Filters: %s

    @@ -213,6 +233,8 @@ func Dashboard(data DashboardData) templ.Component { html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")), html.EscapeString(strings.Join(site.Filters, "\n")), site.ID, + site.ID, + site.ID, targets.String(), html.EscapeString(formatFilters(site.Filters)), html.EscapeString(scanState), @@ -590,6 +612,14 @@ func formatFlash(code string) string { return "MySQL dump operation added." case "mysql-invalid": 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": return "Target removed." case "target-not-found": @@ -610,6 +640,12 @@ func targetModeLabel(mode string) string { if mode == "mysql_dump" { return "mysql dump" } + if mode == "podman_save" { + return "podman save" + } + if mode == "podman_export" { + return "podman export" + } return "directory" } @@ -631,7 +667,7 @@ func formatFilters(filters []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 "ok"