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
This commit is contained in:
1 parent
70e1b1cc6e
commit
5c05a27ae2
2 files changed
+164
-20
No files matched your search
+8
-12
@@ -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 .
|
||||
```
|
||||
|
||||
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 # 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.
|
||||
+156
-8
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user