restic backups
This commit is contained in:
parent
a8d3bc66ad
commit
e0311f9d8f
20
AGENTS.md
20
AGENTS.md
|
|
@ -40,6 +40,26 @@ LOG_FORMAT=json LOG_LEVEL=debug LOG_FILE=./logs/satoru.log go run ./cmd/satoru
|
|||
tail -f ./logs/satoru.log | jq
|
||||
```
|
||||
|
||||
## Config File (Optional)
|
||||
You can configure runtime variables in a key-value file:
|
||||
- default path: `/Users/peterli/git/satoru/data/satoru.conf`
|
||||
- override path: `SATORU_CONFIG_FILE=/absolute/path/to/file`
|
||||
- on first run, Satoru creates an example config automatically if missing
|
||||
- if no explicit restic password is configured, Satoru auto-generates `RESTIC_PASSWORD` and appends it on first boot
|
||||
|
||||
Format:
|
||||
```bash
|
||||
# comments allowed
|
||||
RESTIC_PASSWORD=example-local-password
|
||||
RESTIC_PASSWORD2=example-b2-password
|
||||
B2_APPLICATION_ID=xxxx
|
||||
B2_APPLICATION_KEY=yyyy
|
||||
SATORU_RESTIC_B2_REPOSITORY=b2:bucket-name:repo-prefix
|
||||
```
|
||||
|
||||
Precedence:
|
||||
- environment variable overrides config file value.
|
||||
|
||||
## Debug Logging Expectation
|
||||
Be proactive with debug-level logging for:
|
||||
1. DB state changes (job/site/session mutations).
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ func (a *app) runRetentionJob(ctx context.Context, job store.Job, site store.Sit
|
|||
}
|
||||
|
||||
func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Site) (string, string) {
|
||||
b2Repo := strings.TrimSpace(os.Getenv("SATORU_RESTIC_B2_REPOSITORY"))
|
||||
b2Repo := configValue("SATORU_RESTIC_B2_REPOSITORY")
|
||||
if b2Repo == "" {
|
||||
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "restic sync skipped: SATORU_RESTIC_B2_REPOSITORY not set"})
|
||||
return "warning", "restic sync skipped: B2 repository not configured"
|
||||
|
|
@ -385,13 +385,25 @@ func ensureResticRepo(ctx context.Context, repoPath string) error {
|
|||
|
||||
func resticEnv() []string {
|
||||
env := os.Environ()
|
||||
password := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD"))
|
||||
passwordFile := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE"))
|
||||
password2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD2"))
|
||||
passwordFile2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE2"))
|
||||
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 == "" {
|
||||
// Use repo2-specific password when provided; otherwise fall back to primary/default password.
|
||||
if password != "" {
|
||||
password2 = password
|
||||
} else {
|
||||
password2 = configuredResticPassword()
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
env = append(env, "RESTIC_PASSWORD="+password)
|
||||
}
|
||||
|
|
@ -404,6 +416,13 @@ func resticEnv() []string {
|
|||
if passwordFile2 != "" {
|
||||
env = append(env, "RESTIC_PASSWORD_FILE2="+passwordFile2)
|
||||
}
|
||||
// Prefer clearer B2 application naming and map to restic backend vars.
|
||||
if b2AccountID == "" && b2ApplicationID != "" {
|
||||
env = append(env, "B2_ACCOUNT_ID="+b2ApplicationID)
|
||||
}
|
||||
if b2AccountKey == "" && b2ApplicationKey != "" {
|
||||
env = append(env, "B2_ACCOUNT_KEY="+b2ApplicationKey)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,46 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const defaultResticPassword = "satoru-change-me"
|
||||
var (
|
||||
confOnce sync.Once
|
||||
confData map[string]string
|
||||
)
|
||||
|
||||
const defaultConfigBody = `# Satoru configuration file
|
||||
# Environment variables override these values.
|
||||
#
|
||||
# Restic local repository password:
|
||||
# RESTIC_PASSWORD=set-a-strong-value
|
||||
#
|
||||
# Restic B2 repository password (for repo2):
|
||||
# RESTIC_PASSWORD2=set-a-strong-second-value
|
||||
#
|
||||
# Backblaze B2 credentials (application key format):
|
||||
# B2_APPLICATION_ID=your-application-key-id
|
||||
# B2_APPLICATION_KEY=your-application-key
|
||||
#
|
||||
# B2 restic repository target:
|
||||
# SATORU_RESTIC_B2_REPOSITORY=b2:your-bucket:satoru-repo
|
||||
#
|
||||
# Optional local paths:
|
||||
# SATORU_RESTIC_REPO=./repos/restic
|
||||
# SATORU_STAGING_ROOT=./backups
|
||||
`
|
||||
|
||||
func getenvDefault(k, d string) string {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
v := configValue(k)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
|
|
@ -17,7 +48,7 @@ func getenvDefault(k, d string) string {
|
|||
}
|
||||
|
||||
func parsePositiveIntEnv(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
raw := configValue(name)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -29,11 +60,113 @@ func parsePositiveIntEnv(name string, fallback int) int {
|
|||
}
|
||||
|
||||
func configuredResticPassword() string {
|
||||
return getenvDefault("SATORU_RESTIC_PASSWORD", defaultResticPassword)
|
||||
if v := configValue("RESTIC_PASSWORD"); v != "" {
|
||||
return v
|
||||
}
|
||||
return configValue("SATORU_RESTIC_PASSWORD")
|
||||
}
|
||||
|
||||
func isUsingDefaultResticPassword() bool {
|
||||
return strings.TrimSpace(os.Getenv("RESTIC_PASSWORD")) == "" &&
|
||||
strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE")) == "" &&
|
||||
strings.TrimSpace(os.Getenv("SATORU_RESTIC_PASSWORD")) == ""
|
||||
func isMissingExplicitResticPassword() bool {
|
||||
return configValue("RESTIC_PASSWORD") == "" &&
|
||||
configValue("RESTIC_PASSWORD_FILE") == "" &&
|
||||
configValue("SATORU_RESTIC_PASSWORD") == ""
|
||||
}
|
||||
|
||||
func configValue(key string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
confOnce.Do(loadConf)
|
||||
if confData == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(confData[key])
|
||||
}
|
||||
|
||||
func loadConf() {
|
||||
confData = map[string]string{}
|
||||
confPath := resolveConfigPath()
|
||||
|
||||
f, err := os.Open(confPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.Trim(v, `"`)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
confData[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func resolveConfigPath() string {
|
||||
confPath := strings.TrimSpace(os.Getenv("SATORU_CONFIG_FILE"))
|
||||
if confPath != "" {
|
||||
return confPath
|
||||
}
|
||||
return filepath.Join("data", "satoru.conf")
|
||||
}
|
||||
|
||||
func ensureExampleConfigFile() (string, bool, error) {
|
||||
confPath := resolveConfigPath()
|
||||
if _, err := os.Stat(confPath); err == nil {
|
||||
return confPath, false, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return confPath, false, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(confPath), 0o755); err != nil {
|
||||
return confPath, false, err
|
||||
}
|
||||
if err := os.WriteFile(confPath, []byte(defaultConfigBody), 0o644); err != nil {
|
||||
return confPath, false, err
|
||||
}
|
||||
return confPath, true, nil
|
||||
}
|
||||
|
||||
func ensureGeneratedResticPasswordInConfig() (bool, error) {
|
||||
if !isMissingExplicitResticPassword() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
confPath := resolveConfigPath()
|
||||
password, err := generateRandomSecret(32)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
f, err := os.OpenFile(confPath, os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := fmt.Fprintf(f, "\n# Auto-generated on first startup\nRESTIC_PASSWORD=%s\n", password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if confData != nil {
|
||||
confData["RESTIC_PASSWORD"] = password
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func generateRandomSecret(numBytes int) (string, error) {
|
||||
buf := make([]byte, numBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ func (a *app) executeJob(ctx context.Context, job store.Job) {
|
|||
summary = "job canceled by user"
|
||||
}
|
||||
_ = a.store.CompleteJob(jobCtx, job.ID, status, summary)
|
||||
_ = a.store.UpdateSiteRunResult(jobCtx, site.ID, status, summary, time.Now())
|
||||
a.log.Info("job completed", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("job_type", job.Type), zap.String("status", status), zap.String("summary", summary))
|
||||
case jobTypeResticSync:
|
||||
status, summary := a.runResticSyncJob(jobCtx, job, site)
|
||||
|
|
@ -115,6 +116,7 @@ func (a *app) executeJob(ctx context.Context, job store.Job) {
|
|||
summary = "job canceled by user"
|
||||
}
|
||||
_ = a.store.CompleteJob(jobCtx, job.ID, status, summary)
|
||||
_ = a.store.UpdateSiteRunResult(jobCtx, site.ID, status, summary, time.Now())
|
||||
a.log.Info("job completed", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("job_type", job.Type), zap.String("status", status), zap.String("summary", summary))
|
||||
default:
|
||||
summary := "unknown job type"
|
||||
|
|
@ -273,6 +275,16 @@ func (a *app) enqueueBackupJob(ctx context.Context, siteID int64) (store.Job, er
|
|||
return job, nil
|
||||
}
|
||||
|
||||
func (a *app) enqueueResticSyncJob(ctx context.Context, siteID int64) (store.Job, error) {
|
||||
job, err := a.store.CreateJob(ctx, siteID, jobTypeResticSync)
|
||||
if err != nil {
|
||||
return store.Job{}, err
|
||||
}
|
||||
_ = a.store.UpdateSiteRunResult(ctx, siteID, "queued", fmt.Sprintf("Job #%d queued (restic_sync)", job.ID), time.Now())
|
||||
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "Restic sync job queued"})
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (a *app) latestJobForSite(ctx context.Context, siteID int64) (store.Job, error) {
|
||||
jobs, err := a.store.ListRecentJobs(ctx, 200)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ type app struct {
|
|||
jobClaimMu sync.Mutex
|
||||
jobCancelMu sync.Mutex
|
||||
jobCancels map[int64]context.CancelFunc
|
||||
b2ProbeNote string
|
||||
b2InitRepo string
|
||||
b2InitReady bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
@ -46,6 +49,23 @@ func main() {
|
|||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
logger.Fatal("failed to create data directory", zap.Error(err))
|
||||
}
|
||||
confPath, created, err := ensureExampleConfigFile()
|
||||
if err != nil {
|
||||
logger.Fatal("failed to ensure config file", zap.Error(err))
|
||||
}
|
||||
if created {
|
||||
logger.Info("created example config file", zap.String("path", confPath))
|
||||
}
|
||||
generated, err := ensureGeneratedResticPasswordInConfig()
|
||||
if err != nil {
|
||||
logger.Fatal("failed to ensure restic password", zap.Error(err))
|
||||
}
|
||||
if generated {
|
||||
logger.Info("generated restic password in config", zap.String("path", confPath))
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("SATORU_CONFIG_FILE")) != "" {
|
||||
logger.Info("using explicit config file", zap.String("path", os.Getenv("SATORU_CONFIG_FILE")))
|
||||
}
|
||||
dbPath := filepath.Join("data", "satoru.db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
|
|
@ -59,9 +79,7 @@ func main() {
|
|||
log: logger,
|
||||
jobCancels: map[int64]context.CancelFunc{},
|
||||
}
|
||||
if isUsingDefaultResticPassword() {
|
||||
logger.Warn("using built-in default restic password; set SATORU_RESTIC_PASSWORD or RESTIC_PASSWORD for production")
|
||||
}
|
||||
a.runStartupB2Probe(context.Background())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -76,10 +94,12 @@ func main() {
|
|||
|
||||
r.Get("/", a.handleHome)
|
||||
r.Get("/ws/progress", a.handleProgressWebSocket)
|
||||
r.Post("/b2/init", a.handleB2Init)
|
||||
r.Get("/account/password", a.handlePasswordPage)
|
||||
r.Post("/account/password", a.handlePasswordSubmit)
|
||||
r.Post("/sites", a.handleSiteCreate)
|
||||
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}/cancel", a.handleSiteCancel)
|
||||
r.Post("/sites/{id}/restart", a.handleSiteRestart)
|
||||
|
|
@ -118,13 +138,33 @@ func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
|||
ScanInterval: scanInterval,
|
||||
User: user,
|
||||
Sites: sites,
|
||||
RuntimeChecks: runtimeChecks(),
|
||||
RuntimeChecks: a.runtimeChecks(),
|
||||
FlashMessage: r.URL.Query().Get("msg"),
|
||||
WorkflowStages: defaultWorkflowStages(),
|
||||
ShowB2InitButton: a.b2InitReady,
|
||||
B2Repo: a.b2InitRepo,
|
||||
}
|
||||
resticCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
data.ResticRepoPath, data.ResticRepoSize, data.ResticSnapshots = a.localResticOverview(resticCtx)
|
||||
cancel()
|
||||
templ.Handler(webui.Dashboard(data)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *app) handleB2Init(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.initB2Repo(r.Context()); err != nil {
|
||||
a.log.Warn("b2 init failed", zap.Error(err))
|
||||
a.runStartupB2Probe(context.Background())
|
||||
http.Redirect(w, r, "/?msg=b2-init-failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.runStartupB2Probe(context.Background())
|
||||
http.Redirect(w, r, "/?msg=b2-init-ok", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUserWithRollingSession(w, r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
|
@ -355,6 +395,33 @@ func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
|
|||
http.Redirect(w, r, "/?msg=job-queued", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) handleSiteSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
id, 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(), id); 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 := a.enqueueResticSyncJob(r.Context(), id); err != nil {
|
||||
http.Error(w, "failed to queue sync", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?msg=sync-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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resticSnapshotRow struct {
|
||||
ID string `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (a *app) localResticOverview(ctx context.Context) (string, string, []string) {
|
||||
repoPath := getenvDefault("SATORU_RESTIC_REPO", defaultResticRepo)
|
||||
sizeText := "not initialized"
|
||||
|
||||
if size, err := dirSizeBytes(repoPath); err == nil {
|
||||
sizeText = humanBytes(size)
|
||||
} else if !os.IsNotExist(err) {
|
||||
sizeText = "error: " + strings.TrimSpace(err.Error())
|
||||
}
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cmdCtx, "restic", "-r", repoPath, "snapshots", "--json")
|
||||
cmd.Env = resticEnv()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return repoPath, sizeText, []string{"error: " + clipDetail(msg, 180)}
|
||||
}
|
||||
|
||||
var rows []resticSnapshotRow
|
||||
if err := json.Unmarshal(out, &rows); err != nil {
|
||||
return repoPath, sizeText, []string{"error: invalid snapshots json"}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return repoPath, sizeText, []string{"(no snapshots yet)"}
|
||||
}
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) })
|
||||
limit := len(rows)
|
||||
if limit > 25 {
|
||||
limit = 25
|
||||
}
|
||||
lines := make([]string, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
r := rows[i]
|
||||
tags := "(no tags)"
|
||||
if len(r.Tags) > 0 {
|
||||
tags = strings.Join(r.Tags, ",")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s", shortID(r.ID), r.Time.Local().Format(time.RFC3339), tags))
|
||||
}
|
||||
return repoPath, sizeText, lines
|
||||
}
|
||||
|
||||
func shortID(v string) string {
|
||||
if len(v) <= 8 {
|
||||
return v
|
||||
}
|
||||
return v[:8]
|
||||
}
|
||||
|
||||
func dirSizeBytes(root string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func humanBytes(v int64) string {
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
fv := float64(v)
|
||||
u := 0
|
||||
for fv >= 1024 && u < len(units)-1 {
|
||||
fv /= 1024
|
||||
u++
|
||||
}
|
||||
if u == 0 {
|
||||
return fmt.Sprintf("%d %s", v, units[u])
|
||||
}
|
||||
return fmt.Sprintf("%.2f %s", fv, units[u])
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ import (
|
|||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func runtimeChecks() []webui.RuntimeCheck {
|
||||
tools := []string{"restic", "rsync", "ssh"}
|
||||
func (a *app) runtimeChecks() []webui.RuntimeCheck {
|
||||
tools := []string{"restic", "rsync", "ssh", "b2"}
|
||||
out := make([]webui.RuntimeCheck, 0, len(tools))
|
||||
for _, name := range tools {
|
||||
path, err := exec.LookPath(name)
|
||||
|
|
@ -25,11 +25,86 @@ func runtimeChecks() []webui.RuntimeCheck {
|
|||
out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"})
|
||||
continue
|
||||
}
|
||||
out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: path})
|
||||
details := path
|
||||
if name == "b2" && a.b2ProbeNote != "" {
|
||||
details = details + " | " + a.b2ProbeNote
|
||||
}
|
||||
out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: details})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) runStartupB2Probe(ctx context.Context) {
|
||||
if _, err := exec.LookPath("b2"); err != nil {
|
||||
a.b2ProbeNote = "startup probe skipped: b2 cli not installed"
|
||||
a.b2InitReady = false
|
||||
a.b2InitRepo = ""
|
||||
return
|
||||
}
|
||||
|
||||
repo := configValue("SATORU_RESTIC_B2_REPOSITORY")
|
||||
a.b2InitRepo = repo
|
||||
if repo == "" {
|
||||
a.b2ProbeNote = "startup probe skipped: SATORU_RESTIC_B2_REPOSITORY not set"
|
||||
a.b2InitReady = false
|
||||
return
|
||||
}
|
||||
hasID := configValue("B2_ACCOUNT_ID") != "" || configValue("B2_APPLICATION_ID") != ""
|
||||
hasKey := configValue("B2_ACCOUNT_KEY") != "" || configValue("B2_APPLICATION_KEY") != ""
|
||||
if !hasID || !hasKey {
|
||||
a.b2ProbeNote = "startup probe skipped: B2 credentials not fully set"
|
||||
a.b2InitReady = false
|
||||
return
|
||||
}
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "restic", "-r", repo, "cat", "config")
|
||||
cmd.Env = resticEnv()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
a.b2InitReady = strings.Contains(msg, "repository does not exist")
|
||||
a.b2ProbeNote = "startup probe failed: " + clipDetail(msg, 140)
|
||||
a.log.Warn("b2 startup probe failed", zap.String("repo", repo), zap.String("detail", msg))
|
||||
return
|
||||
}
|
||||
a.b2InitReady = false
|
||||
a.b2ProbeNote = "startup probe ok: b2 reachable and repo exists"
|
||||
a.log.Info("b2 startup probe ok", zap.String("repo", repo))
|
||||
}
|
||||
|
||||
func (a *app) initB2Repo(ctx context.Context) error {
|
||||
repo := configValue("SATORU_RESTIC_B2_REPOSITORY")
|
||||
if repo == "" {
|
||||
return errors.New("SATORU_RESTIC_B2_REPOSITORY not set")
|
||||
}
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cmdCtx, "restic", "-r", repo, "init")
|
||||
cmd.Env = resticEnv()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clipDetail(v string, max int) string {
|
||||
if len(v) <= max {
|
||||
return v
|
||||
}
|
||||
return v[:max-3] + "..."
|
||||
}
|
||||
|
||||
func runSSHHello(ctx context.Context, site store.Site) (string, string) {
|
||||
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ type DashboardData struct {
|
|||
RuntimeChecks []RuntimeCheck
|
||||
WorkflowStages []WorkflowStage
|
||||
FlashMessage string
|
||||
ShowB2InitButton bool
|
||||
B2Repo string
|
||||
ResticRepoPath string
|
||||
ResticRepoSize string
|
||||
ResticSnapshots []string
|
||||
}
|
||||
|
||||
func Dashboard(data DashboardData) templ.Component {
|
||||
|
|
@ -148,6 +153,9 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
<form method="post" action="/sites/%d/restart">
|
||||
<button class="button ghost" type="submit">Restart backup</button>
|
||||
</form>
|
||||
<form method="post" action="/sites/%d/sync">
|
||||
<button class="button ghost" type="submit">Sync to B2</button>
|
||||
</form>
|
||||
<form method="post" action="/sites/%d/delete" onsubmit="return confirm('Delete this site?');">
|
||||
<button class="button ghost" type="submit">Delete</button>
|
||||
</form>
|
||||
|
|
@ -197,6 +205,7 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
site.ID,
|
||||
site.ID,
|
||||
site.ID,
|
||||
site.ID,
|
||||
html.EscapeString(site.SSHUser),
|
||||
html.EscapeString(site.Host),
|
||||
site.Port,
|
||||
|
|
@ -228,6 +237,17 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
if data.FlashMessage != "" {
|
||||
flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(data.FlashMessage)))
|
||||
}
|
||||
b2Init := ""
|
||||
if data.ShowB2InitButton {
|
||||
b2Init = fmt.Sprintf(`<form method="post" action="/b2/init"><button class="button button-sm" type="submit">Initialize B2 Repo</button><p class="muted">Repository: <code>%s</code></p></form>`, html.EscapeString(data.B2Repo))
|
||||
}
|
||||
var resticSnaps strings.Builder
|
||||
for _, s := range data.ResticSnapshots {
|
||||
resticSnaps.WriteString(fmt.Sprintf(`<li><code>%s</code></li>`, html.EscapeString(s)))
|
||||
}
|
||||
if len(data.ResticSnapshots) == 0 {
|
||||
resticSnaps.WriteString(`<li class="muted">(no snapshots yet)</li>`)
|
||||
}
|
||||
|
||||
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
|
|
@ -258,6 +278,7 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
<div class="site-nav-list">%s</div>
|
||||
<h2>Runtime Checks</h2>
|
||||
<ul class="status-list">%s</ul>
|
||||
%s
|
||||
</aside>
|
||||
<section class="site-main-panel">
|
||||
<section>
|
||||
|
|
@ -286,6 +307,13 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
</div>
|
||||
<pre class="output" id="live-events">No live events yet.</pre>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Local Restic Repository</h2>
|
||||
<p class="muted">Path: <code>%s</code></p>
|
||||
<p class="muted">Size: %s</p>
|
||||
<p class="muted">Snapshots:</p>
|
||||
<ul class="target-list">%s</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Planned Workflow</h2>
|
||||
<ol class="workflow">%s</ol>
|
||||
|
|
@ -446,11 +474,15 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
if (siteButtons.length === 0) {
|
||||
return;
|
||||
}
|
||||
const stored = Number(window.localStorage.getItem("satoru.active_site_id") || "");
|
||||
const hasStored = siteButtons.some((btn) => Number(btn.getAttribute("data-site-select")) === stored);
|
||||
const firstID = Number(siteButtons[0].getAttribute("data-site-select"));
|
||||
setActiveSite(firstID);
|
||||
setActiveSite(hasStored ? stored : firstID);
|
||||
siteButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
setActiveSite(Number(btn.getAttribute("data-site-select")));
|
||||
const id = Number(btn.getAttribute("data-site-select"));
|
||||
setActiveSite(id);
|
||||
window.localStorage.setItem("satoru.active_site_id", String(id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -517,7 +549,11 @@ func Dashboard(data DashboardData) templ.Component {
|
|||
flash,
|
||||
siteNav.String(),
|
||||
checks.String(),
|
||||
b2Init,
|
||||
sites.String(),
|
||||
html.EscapeString(data.ResticRepoPath),
|
||||
html.EscapeString(data.ResticRepoSize),
|
||||
resticSnaps.String(),
|
||||
flows.String(),
|
||||
))
|
||||
return err
|
||||
|
|
@ -530,6 +566,8 @@ func formatFlash(code string) string {
|
|||
return "Site added."
|
||||
case "job-queued":
|
||||
return "Backup job queued."
|
||||
case "sync-queued":
|
||||
return "Restic sync job queued."
|
||||
case "job-cancel-requested":
|
||||
return "Cancel requested for active job."
|
||||
case "job-restarted":
|
||||
|
|
@ -556,6 +594,10 @@ func formatFlash(code string) string {
|
|||
return "Target removed."
|
||||
case "target-not-found":
|
||||
return "Target not found."
|
||||
case "b2-init-ok":
|
||||
return "B2 restic repository initialized."
|
||||
case "b2-init-failed":
|
||||
return "Failed to initialize B2 restic repository."
|
||||
default:
|
||||
return code
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue