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
|
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
|
## Debug Logging Expectation
|
||||||
Be proactive with debug-level logging for:
|
Be proactive with debug-level logging for:
|
||||||
1. DB state changes (job/site/session mutations).
|
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) {
|
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 == "" {
|
if b2Repo == "" {
|
||||||
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "restic sync skipped: SATORU_RESTIC_B2_REPOSITORY not set"})
|
_ = 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"
|
return "warning", "restic sync skipped: B2 repository not configured"
|
||||||
|
|
@ -385,13 +385,25 @@ func ensureResticRepo(ctx context.Context, repoPath string) error {
|
||||||
|
|
||||||
func resticEnv() []string {
|
func resticEnv() []string {
|
||||||
env := os.Environ()
|
env := os.Environ()
|
||||||
password := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD"))
|
password := configValue("RESTIC_PASSWORD")
|
||||||
passwordFile := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE"))
|
passwordFile := configValue("RESTIC_PASSWORD_FILE")
|
||||||
password2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD2"))
|
password2 := configValue("RESTIC_PASSWORD2")
|
||||||
passwordFile2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE2"))
|
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 == "" {
|
if password == "" && passwordFile == "" {
|
||||||
password = configuredResticPassword()
|
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 != "" {
|
if password != "" {
|
||||||
env = append(env, "RESTIC_PASSWORD="+password)
|
env = append(env, "RESTIC_PASSWORD="+password)
|
||||||
}
|
}
|
||||||
|
|
@ -404,6 +416,13 @@ func resticEnv() []string {
|
||||||
if passwordFile2 != "" {
|
if passwordFile2 != "" {
|
||||||
env = append(env, "RESTIC_PASSWORD_FILE2="+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
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,46 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 {
|
func getenvDefault(k, d string) string {
|
||||||
v := strings.TrimSpace(os.Getenv(k))
|
v := configValue(k)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
@ -17,7 +48,7 @@ func getenvDefault(k, d string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePositiveIntEnv(name string, fallback int) int {
|
func parsePositiveIntEnv(name string, fallback int) int {
|
||||||
raw := strings.TrimSpace(os.Getenv(name))
|
raw := configValue(name)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
@ -29,11 +60,113 @@ func parsePositiveIntEnv(name string, fallback int) int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func configuredResticPassword() string {
|
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 {
|
func isMissingExplicitResticPassword() bool {
|
||||||
return strings.TrimSpace(os.Getenv("RESTIC_PASSWORD")) == "" &&
|
return configValue("RESTIC_PASSWORD") == "" &&
|
||||||
strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE")) == "" &&
|
configValue("RESTIC_PASSWORD_FILE") == "" &&
|
||||||
strings.TrimSpace(os.Getenv("SATORU_RESTIC_PASSWORD")) == ""
|
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"
|
summary = "job canceled by user"
|
||||||
}
|
}
|
||||||
_ = a.store.CompleteJob(jobCtx, job.ID, status, summary)
|
_ = 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))
|
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:
|
case jobTypeResticSync:
|
||||||
status, summary := a.runResticSyncJob(jobCtx, job, site)
|
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"
|
summary = "job canceled by user"
|
||||||
}
|
}
|
||||||
_ = a.store.CompleteJob(jobCtx, job.ID, status, summary)
|
_ = 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))
|
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:
|
default:
|
||||||
summary := "unknown job type"
|
summary := "unknown job type"
|
||||||
|
|
@ -273,6 +275,16 @@ func (a *app) enqueueBackupJob(ctx context.Context, siteID int64) (store.Job, er
|
||||||
return job, nil
|
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) {
|
func (a *app) latestJobForSite(ctx context.Context, siteID int64) (store.Job, error) {
|
||||||
jobs, err := a.store.ListRecentJobs(ctx, 200)
|
jobs, err := a.store.ListRecentJobs(ctx, 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ type app struct {
|
||||||
jobClaimMu sync.Mutex
|
jobClaimMu sync.Mutex
|
||||||
jobCancelMu sync.Mutex
|
jobCancelMu sync.Mutex
|
||||||
jobCancels map[int64]context.CancelFunc
|
jobCancels map[int64]context.CancelFunc
|
||||||
|
b2ProbeNote string
|
||||||
|
b2InitRepo string
|
||||||
|
b2InitReady bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -46,6 +49,23 @@ func main() {
|
||||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||||
logger.Fatal("failed to create data directory", zap.Error(err))
|
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")
|
dbPath := filepath.Join("data", "satoru.db")
|
||||||
st, err := store.Open(dbPath)
|
st, err := store.Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -59,9 +79,7 @@ func main() {
|
||||||
log: logger,
|
log: logger,
|
||||||
jobCancels: map[int64]context.CancelFunc{},
|
jobCancels: map[int64]context.CancelFunc{},
|
||||||
}
|
}
|
||||||
if isUsingDefaultResticPassword() {
|
a.runStartupB2Probe(context.Background())
|
||||||
logger.Warn("using built-in default restic password; set SATORU_RESTIC_PASSWORD or RESTIC_PASSWORD for production")
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -76,10 +94,12 @@ func main() {
|
||||||
|
|
||||||
r.Get("/", a.handleHome)
|
r.Get("/", a.handleHome)
|
||||||
r.Get("/ws/progress", a.handleProgressWebSocket)
|
r.Get("/ws/progress", a.handleProgressWebSocket)
|
||||||
|
r.Post("/b2/init", a.handleB2Init)
|
||||||
r.Get("/account/password", a.handlePasswordPage)
|
r.Get("/account/password", a.handlePasswordPage)
|
||||||
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}/sync", a.handleSiteSyncNow)
|
||||||
r.Post("/sites/{id}/mysql-dumps", a.handleSiteAddMySQLDump)
|
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)
|
||||||
|
|
@ -114,17 +134,37 @@ func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
data := webui.DashboardData{
|
data := webui.DashboardData{
|
||||||
Now: time.Now(),
|
Now: time.Now(),
|
||||||
ScanInterval: scanInterval,
|
ScanInterval: scanInterval,
|
||||||
User: user,
|
User: user,
|
||||||
Sites: sites,
|
Sites: sites,
|
||||||
RuntimeChecks: runtimeChecks(),
|
RuntimeChecks: a.runtimeChecks(),
|
||||||
FlashMessage: r.URL.Query().Get("msg"),
|
FlashMessage: r.URL.Query().Get("msg"),
|
||||||
WorkflowStages: defaultWorkflowStages(),
|
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)
|
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) {
|
func (a *app) handleSignupPage(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, "/", http.StatusSeeOther)
|
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)
|
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) {
|
func (a *app) handleSiteAddMySQLDump(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,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"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runtimeChecks() []webui.RuntimeCheck {
|
func (a *app) runtimeChecks() []webui.RuntimeCheck {
|
||||||
tools := []string{"restic", "rsync", "ssh"}
|
tools := []string{"restic", "rsync", "ssh", "b2"}
|
||||||
out := make([]webui.RuntimeCheck, 0, len(tools))
|
out := make([]webui.RuntimeCheck, 0, len(tools))
|
||||||
for _, name := range tools {
|
for _, name := range tools {
|
||||||
path, err := exec.LookPath(name)
|
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"})
|
out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"})
|
||||||
continue
|
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
|
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) {
|
func runSSHHello(ctx context.Context, site store.Site) (string, string) {
|
||||||
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,18 @@ type WorkflowStage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardData struct {
|
type DashboardData struct {
|
||||||
Now time.Time
|
Now time.Time
|
||||||
ScanInterval time.Duration
|
ScanInterval time.Duration
|
||||||
User store.User
|
User store.User
|
||||||
Sites []store.Site
|
Sites []store.Site
|
||||||
RuntimeChecks []RuntimeCheck
|
RuntimeChecks []RuntimeCheck
|
||||||
WorkflowStages []WorkflowStage
|
WorkflowStages []WorkflowStage
|
||||||
FlashMessage string
|
FlashMessage string
|
||||||
|
ShowB2InitButton bool
|
||||||
|
B2Repo string
|
||||||
|
ResticRepoPath string
|
||||||
|
ResticRepoSize string
|
||||||
|
ResticSnapshots []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Dashboard(data DashboardData) templ.Component {
|
func Dashboard(data DashboardData) templ.Component {
|
||||||
|
|
@ -148,6 +153,9 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
<form method="post" action="/sites/%d/restart">
|
<form method="post" action="/sites/%d/restart">
|
||||||
<button class="button ghost" type="submit">Restart backup</button>
|
<button class="button ghost" type="submit">Restart backup</button>
|
||||||
</form>
|
</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?');">
|
<form method="post" action="/sites/%d/delete" onsubmit="return confirm('Delete this site?');">
|
||||||
<button class="button ghost" type="submit">Delete</button>
|
<button class="button ghost" type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -197,6 +205,7 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
site.ID,
|
site.ID,
|
||||||
site.ID,
|
site.ID,
|
||||||
site.ID,
|
site.ID,
|
||||||
|
site.ID,
|
||||||
html.EscapeString(site.SSHUser),
|
html.EscapeString(site.SSHUser),
|
||||||
html.EscapeString(site.Host),
|
html.EscapeString(site.Host),
|
||||||
site.Port,
|
site.Port,
|
||||||
|
|
@ -228,6 +237,17 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
if data.FlashMessage != "" {
|
if data.FlashMessage != "" {
|
||||||
flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(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>
|
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -258,6 +278,7 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
<div class="site-nav-list">%s</div>
|
<div class="site-nav-list">%s</div>
|
||||||
<h2>Runtime Checks</h2>
|
<h2>Runtime Checks</h2>
|
||||||
<ul class="status-list">%s</ul>
|
<ul class="status-list">%s</ul>
|
||||||
|
%s
|
||||||
</aside>
|
</aside>
|
||||||
<section class="site-main-panel">
|
<section class="site-main-panel">
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -286,6 +307,13 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
</div>
|
</div>
|
||||||
<pre class="output" id="live-events">No live events yet.</pre>
|
<pre class="output" id="live-events">No live events yet.</pre>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Planned Workflow</h2>
|
<h2>Planned Workflow</h2>
|
||||||
<ol class="workflow">%s</ol>
|
<ol class="workflow">%s</ol>
|
||||||
|
|
@ -446,11 +474,15 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
if (siteButtons.length === 0) {
|
if (siteButtons.length === 0) {
|
||||||
return;
|
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"));
|
const firstID = Number(siteButtons[0].getAttribute("data-site-select"));
|
||||||
setActiveSite(firstID);
|
setActiveSite(hasStored ? stored : firstID);
|
||||||
siteButtons.forEach((btn) => {
|
siteButtons.forEach((btn) => {
|
||||||
btn.addEventListener("click", function () {
|
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,
|
flash,
|
||||||
siteNav.String(),
|
siteNav.String(),
|
||||||
checks.String(),
|
checks.String(),
|
||||||
|
b2Init,
|
||||||
sites.String(),
|
sites.String(),
|
||||||
|
html.EscapeString(data.ResticRepoPath),
|
||||||
|
html.EscapeString(data.ResticRepoSize),
|
||||||
|
resticSnaps.String(),
|
||||||
flows.String(),
|
flows.String(),
|
||||||
))
|
))
|
||||||
return err
|
return err
|
||||||
|
|
@ -530,6 +566,8 @@ func formatFlash(code string) string {
|
||||||
return "Site added."
|
return "Site added."
|
||||||
case "job-queued":
|
case "job-queued":
|
||||||
return "Backup job queued."
|
return "Backup job queued."
|
||||||
|
case "sync-queued":
|
||||||
|
return "Restic sync job queued."
|
||||||
case "job-cancel-requested":
|
case "job-cancel-requested":
|
||||||
return "Cancel requested for active job."
|
return "Cancel requested for active job."
|
||||||
case "job-restarted":
|
case "job-restarted":
|
||||||
|
|
@ -556,6 +594,10 @@ func formatFlash(code string) string {
|
||||||
return "Target removed."
|
return "Target removed."
|
||||||
case "target-not-found":
|
case "target-not-found":
|
||||||
return "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:
|
default:
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue