From 5c05a27ae2c7424def20f661888a5d1576190e3d Mon Sep 17 00:00:00 2001 From: Phil Date: Thu, 5 Nov 2026 16:40:40 +0000 Subject: [PATCH] Port Home Assistant backup script to Go (v3.1.1) Rewrite home_assistant_backup.sh as a single Go binary that reads the existing home_assistant_backup.conf unchanged. - Create a backup over SSH, scp it locally, and prune old backups both on the HA host (when free space is low) and locally - Send ntfy and Discord notifications at the same points as the script - Drop the jq and yq dependencies: parse `ha backups --raw-json` and `ha backup new --raw-json` in Go, falling back to `slug:` line parsing - Add timestamped progress output, a heartbeat while HA builds the backup, and live size/percent/speed reporting during the copy - Build the Discord payload with json.Marshal and validate backup slugs before using them in remote commands - Abort with a notification if the remote disk-space check fails - Add -config and -version flags - Build with CGO_ENABLED=0 for a static binary that runs on older glibc --- V3/README.md | 24 ++++---- V3/main.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 166 insertions(+), 22 deletions(-) diff --git a/V3/README.md b/V3/README.md index 3c0f71f..911859d 100644 --- a/V3/README.md +++ b/V3/README.md @@ -1,26 +1,22 @@ -# ha-backup +# ha-backup v3.1.1 Go port of `home_assistant_backup.sh`. Uses the same `home_assistant_backup.conf`. ## Build -``` -CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup . -``` + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup . + +Override the version at build time if needed: + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=3.1.2" -o ha-backup . + +Check the version: + ./ha-backup -version Cross-compile examples: -``` GOOS=linux GOARCH=amd64 go build -o ha-backup-linux-amd64 . GOOS=linux GOARCH=arm64 go build -o ha-backup-linux-arm64 . # Raspberry Pi / ARM NAS -``` ## Run -#### looks for home_assistant_backup.conf next to the binary, then in cwd -``` -./ha-backup -``` - -``` -./ha-backup -config /path/to.conf -``` + ./ha-backup # looks for home_assistant_backup.conf next to the binary, then in cwd + ./ha-backup -config /path/to.conf Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed. diff --git a/V3/main.go b/V3/main.go index 9483640..b8adb9b 100644 --- a/V3/main.go +++ b/V3/main.go @@ -1,4 +1,4 @@ -// ha-backup: creates a Home Assistant backup over SSH, copies it locally, +// ha-backup v3.1.1: creates a Home Assistant backup over SSH, copies it locally, // prunes old backups (remote and local) and sends optional ntfy/Discord // notifications. Go port of home_assistant_backup.sh. // @@ -29,6 +29,11 @@ import ( const defaultConfigName = "home_assistant_backup.conf" +// version is the release version. It can be overridden at build time with: +// +// go build -ldflags "-X main.version=3.1.2" +var version = "3.1.1" + type Config struct { SSHKey string SSHUser string @@ -376,6 +381,130 @@ func removeOldLocalBackups(dir string, days int) error { // main // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Progress reporting +// --------------------------------------------------------------------------- + +func logf(format string, a ...any) { + fmt.Printf("[%s] %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...)) +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +func stdoutIsTerminal() bool { + fi, err := os.Stdout.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} + +// withHeartbeat runs fn and prints a "still running" line every interval so +// long operations don't look hung. +func withHeartbeat(label string, interval time.Duration, fn func() error) error { + start := time.Now() + done := make(chan struct{}) + go func() { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-done: + return + case <-t.C: + logf("%s... still running (%s elapsed)", label, time.Since(start).Round(time.Second)) + } + } + }() + err := fn() + close(done) + return err +} + +// remoteSize returns the size in bytes of a file on the HA host (0 if unknown). +func (r Remote) remoteSize(path string) int64 { + out, err := r.run("wc -c < '" + path + "'") + if err != nil { + return 0 + } + f := strings.Fields(out) + if len(f) == 0 { + return 0 + } + n, _ := strconv.ParseInt(f[0], 10, 64) + return n +} + +// copyWithProgress runs scp while reporting how much has arrived locally. +// On a terminal it updates a single line; otherwise (cron, logs) it prints a +// line every 10 seconds. +func (r Remote) copyWithProgress(remotePath, localPath string) error { + total := r.remoteSize(remotePath) + if total > 0 { + logf("Backup size on Home Assistant: %s", humanBytes(total)) + } + + tty := stdoutIsTerminal() + interval := 10 * time.Second + if tty { + interval = time.Second + } + + stop := make(chan struct{}) + stopped := make(chan struct{}) + start := time.Now() + go func() { + defer close(stopped) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-stop: + if tty { + fmt.Print("\r\033[K") + } + return + case <-t.C: + info, err := os.Stat(localPath) + if err != nil { + continue + } + cur := info.Size() + secs := time.Since(start).Seconds() + speed := int64(0) + if secs > 0 { + speed = int64(float64(cur) / secs) + } + var msg string + if total > 0 { + msg = fmt.Sprintf("Copying: %s / %s (%d%%) at %s/s", + humanBytes(cur), humanBytes(total), cur*100/total, humanBytes(speed)) + } else { + msg = fmt.Sprintf("Copying: %s at %s/s", humanBytes(cur), humanBytes(speed)) + } + if tty { + fmt.Printf("\r\033[K[%s] %s", time.Now().Format("15:04:05"), msg) + } else { + logf("%s", msg) + } + } + } + }() + + err := r.copyFrom(remotePath, localPath) + close(stop) + <-stopped + return err +} + func findConfig(flagPath string) string { if flagPath != "" { return flagPath @@ -391,8 +520,14 @@ func findConfig(flagPath string) string { func main() { confFlag := flag.String("config", "", "path to config file (default: "+defaultConfigName+" next to the binary, then current directory)") + versionFlag := flag.Bool("version", false, "print version and exit") flag.Parse() + if *versionFlag { + fmt.Printf("ha-backup v%s\n", version) + return + } + confPath := findConfig(*confFlag) cfg, err := loadConfig(confPath) if err != nil { @@ -410,41 +545,51 @@ func main() { fail := func(msg string) { full := "Error: " + msg notify.Send(full) - fmt.Println(full) + logf("%s", full) os.Exit(1) } timestamp := time.Now().Format("2006-01-02_15-04-05") start := time.Now() + logf("ha-backup v%s - backup process started (%s)", version, timestamp) notify.Send("Backup process started at " + timestamp) // Free-space check on the Home Assistant host. + logf("Checking free disk space on %s...", cfg.SSHHost) avail, err := remote.availableKB() if err != nil { fail("failed to check disk space on Home Assistant server: " + err.Error()) } + logf("Free space: %s (threshold: %s)", humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024)) if avail < cfg.StorageThresholdKB { - fmt.Printf("Disk space is below the threshold (%d KB available, %d KB required). Proceeding with remote backup cleanup...\n", - avail, cfg.StorageThresholdKB) + logf("Disk space is below the threshold. Running remote backup cleanup...") removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays) } else { - fmt.Println("Sufficient disk space available on Home Assistant server. Skipping remote backup cleanup.") + logf("Sufficient disk space available. Skipping remote backup cleanup.") } // Create the backup. - slug, err := remote.createBackup("auto_backup_" + timestamp) + logf("Creating backup on Home Assistant (this can take a few minutes)...") + var slug string + err = withHeartbeat("Creating backup", 10*time.Second, func() (e error) { + slug, e = remote.createBackup("auto_backup_" + timestamp) + return e + }) if err != nil { fail(err.Error() + ".") } if !slugRe.MatchString(slug) { fail(fmt.Sprintf("unexpected backup slug %q.", slug)) } + logf("Backup created with slug: %s", slug) notify.Send("Backup created successfully with slug: " + slug) // Copy it locally. backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug)) - if err := remote.copyFrom(cfg.BackupFileLocation+"/"+slug+".tar", backupFile); err != nil { + logf("Copying backup to %s ...", backupFile) + copyStart := time.Now() + if err := remote.copyWithProgress(cfg.BackupFileLocation+"/"+slug+".tar", backupFile); err != nil { fail("Failed to copy backup to local machine.") } @@ -452,16 +597,19 @@ func main() { if err != nil { fail("Failed to get backup file size.") } + logf("Copy complete: %s in %s", humanBytes(info.Size()), time.Since(copyStart).Round(time.Second)) // Local retention. + logf("Cleaning up local backups older than %d days...", cfg.LocalCleanupDays) if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil { fail("Failed to clean up old backups locally: " + err.Error()) } notify.Send("Old backups cleaned up successfully.") - fmt.Println("Backup completed successfully: " + backupFile) + logf("Backup completed successfully: %s", backupFile) notify.Send("Backup completed successfully: " + backupFile) runtime := int(time.Since(start).Seconds()) + logf("Total runtime: %d seconds", runtime) notify.Send(fmt.Sprintf("Backup file size: %d bytes. Script runtime: %d seconds.", info.Size(), runtime)) }