### Fixed - Backup failures caused by low disk space on the Home Assistant host are now reported as such, e.g. "not enough disk space on Home Assistant server (1.2 GB free, threshold 2.0 GB)", instead of only "failed to extract backup slug". - A failed `ha backup new` now includes Home Assistant's own error message in the notification and log. ### Changed - Free space is re-checked after the remote cleanup runs. If it is still below `STORAGE_THRESHOLD_KB`, a warning is sent to ntfy/Discord before the backup is attempted. - The remote cleanup now logs how many backups it found, and says when none are older than `REMOTE_BACKUP_CLEANUP` days.
731 lines
21 KiB
Go
731 lines
21 KiB
Go
// ha-backup v3.2.1: creates a Home Assistant backup over SSH, copies it locally,
|
|
// prunes old backups (remote and local), logs to /var/log and sends optional
|
|
// ntfy/Discord notifications. Go port of home_assistant_backup.sh.
|
|
//
|
|
// Only the standard library is used. The system `ssh` and `scp` binaries are
|
|
// still required (same as the original script), but `jq` and `yq` are not.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration (reads the same KEY="value" # comment format as the .sh script)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const defaultConfigName = "home_assistant_backup.conf"
|
|
|
|
// defaultLogFile is where the log is written unless LOG_FILE says otherwise.
|
|
const defaultLogFile = "/var/log/ha-backup.log"
|
|
|
|
// version is the release version. It can be overridden at build time with:
|
|
//
|
|
// go build -ldflags "-X main.version=3.2.1"
|
|
var version = "3.2.1"
|
|
|
|
type Config struct {
|
|
SSHKey string
|
|
SSHUser string
|
|
SSHHost string
|
|
SSHPort string
|
|
|
|
BackupDest string
|
|
BackupFileLocation string
|
|
StorageThresholdKB int64
|
|
LocalCleanupDays int
|
|
RemoteCleanupDays int
|
|
|
|
// LogFile is the path of the log file. Set LOG_FILE="none" to disable
|
|
// file logging.
|
|
LogFile string
|
|
|
|
NtfyEnabled bool
|
|
NtfyServer string
|
|
NtfyTopic string
|
|
|
|
DiscordEnabled bool
|
|
DiscordWebhookURL string
|
|
}
|
|
|
|
func parseValue(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if raw[0] == '"' || raw[0] == '\'' {
|
|
q := raw[0]
|
|
if end := strings.IndexByte(raw[1:], q); end >= 0 {
|
|
return raw[1 : 1+end]
|
|
}
|
|
return raw[1:]
|
|
}
|
|
// Unquoted: strip trailing comment (a '#' preceded by whitespace).
|
|
for i := 1; i < len(raw); i++ {
|
|
if raw[i] == '#' && (raw[i-1] == ' ' || raw[i-1] == '\t') {
|
|
raw = raw[:i]
|
|
break
|
|
}
|
|
}
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
|
|
func readConfFile(path string) (map[string]string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
vals := map[string]string{}
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
line = strings.TrimPrefix(line, "export ")
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
vals[strings.TrimSpace(k)] = parseValue(v)
|
|
}
|
|
return vals, sc.Err()
|
|
}
|
|
|
|
func loadConfig(path string) (*Config, error) {
|
|
m, err := readConfFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
get := func(key, def string) string {
|
|
if v, ok := m[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
cfg := &Config{
|
|
SSHKey: get("HA_SSH_KEY", ""),
|
|
SSHUser: get("HA_SSH_USER", ""),
|
|
SSHHost: get("HA_SSH_HOST", ""),
|
|
SSHPort: get("HA_SSH_PORT", ""),
|
|
BackupDest: get("BACKUP_DEST", ""),
|
|
BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"),
|
|
LogFile: get("LOG_FILE", defaultLogFile),
|
|
NtfyEnabled: get("NTFY_ENABLED", "false") == "true",
|
|
NtfyServer: get("NTFY_SERVER", ""),
|
|
NtfyTopic: get("NTFY_TOPIC", ""),
|
|
DiscordEnabled: get("DISCORD_ENABLED", "false") == "true",
|
|
DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""),
|
|
}
|
|
|
|
if cfg.StorageThresholdKB, err = strconv.ParseInt(get("STORAGE_THRESHOLD_KB", "2097152"), 10, 64); err != nil {
|
|
return nil, fmt.Errorf("invalid STORAGE_THRESHOLD_KB: %w", err)
|
|
}
|
|
if cfg.LocalCleanupDays, err = strconv.Atoi(get("LOCAL_BACKUP_CLEANUP", "90")); err != nil {
|
|
return nil, fmt.Errorf("invalid LOCAL_BACKUP_CLEANUP: %w", err)
|
|
}
|
|
if cfg.RemoteCleanupDays, err = strconv.Atoi(get("REMOTE_BACKUP_CLEANUP", "30")); err != nil {
|
|
return nil, fmt.Errorf("invalid REMOTE_BACKUP_CLEANUP: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// validate mirrors sanity_check() from the shell script.
|
|
func (c *Config) validate() error {
|
|
type req struct{ val, placeholder, name string }
|
|
checks := []req{
|
|
{c.SSHKey, "SSH_PRIVATE_KEY", "HA_SSH_KEY"},
|
|
{c.SSHUser, "SSH_USER", "HA_SSH_USER"},
|
|
{c.SSHHost, "SSH_HOST_IP_OR_HOSTNAME", "HA_SSH_HOST"},
|
|
{c.SSHPort, "SSH_PORT", "HA_SSH_PORT"},
|
|
{c.BackupDest, "BACKUP_FILE_DESTINATION", "BACKUP_DEST"},
|
|
}
|
|
if c.NtfyEnabled {
|
|
checks = append(checks,
|
|
req{c.NtfyServer, "NTFY_SERVER_FQDN_ADDRESS", "NTFY_SERVER"},
|
|
req{c.NtfyTopic, "NTFY_TOPIC", "NTFY_TOPIC"})
|
|
}
|
|
if c.DiscordEnabled {
|
|
checks = append(checks, req{c.DiscordWebhookURL, "DISCORD_WEBHOOK_URL", "DISCORD_WEBHOOK_URL"})
|
|
}
|
|
for _, r := range checks {
|
|
if r.val == "" || r.val == r.placeholder {
|
|
return fmt.Errorf("%s is not set", r.name)
|
|
}
|
|
}
|
|
for _, bin := range []string{"ssh", "scp"} {
|
|
if _, err := exec.LookPath(bin); err != nil {
|
|
return fmt.Errorf("%s is not installed or not in PATH", bin)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Logging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var (
|
|
logMu sync.Mutex
|
|
logFile *os.File // nil when file logging is disabled or could not be opened
|
|
)
|
|
|
|
// openLog opens (or creates) the log file in append mode. An empty path or
|
|
// "none" disables file logging. Console output is never affected.
|
|
func openLog(path string) error {
|
|
if path == "" || strings.EqualFold(path, "none") {
|
|
return nil
|
|
}
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logMu.Lock()
|
|
logFile = f
|
|
logMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// logf prints a timestamped line to the console and, when enabled, appends a
|
|
// line with a full date and time to the log file.
|
|
func logf(format string, a ...any) {
|
|
msg := fmt.Sprintf(format, a...)
|
|
now := time.Now()
|
|
|
|
logMu.Lock()
|
|
defer logMu.Unlock()
|
|
fmt.Printf("[%s] %s\n", now.Format("15:04:05"), msg)
|
|
if logFile != nil {
|
|
fmt.Fprintf(logFile, "%s %s\n", now.Format("2006-01-02 15:04:05"), msg)
|
|
}
|
|
}
|
|
|
|
// stderrWriter returns a writer for subprocess stderr that goes to the
|
|
// terminal and, when enabled, to the log file as well.
|
|
func stderrWriter() io.Writer {
|
|
logMu.Lock()
|
|
defer logMu.Unlock()
|
|
if logFile != nil {
|
|
return io.MultiWriter(os.Stderr, logFile)
|
|
}
|
|
return os.Stderr
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Notifications
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Notifier struct {
|
|
cfg *Config
|
|
client *http.Client
|
|
}
|
|
|
|
func NewNotifier(cfg *Config) *Notifier {
|
|
return &Notifier{cfg: cfg, client: &http.Client{Timeout: 15 * time.Second}}
|
|
}
|
|
|
|
func (n *Notifier) post(url, contentType string, body []byte) {
|
|
resp, err := n.client.Post(url, contentType, bytes.NewReader(body))
|
|
if err != nil {
|
|
logf("notification failed (%s): %v", url, err)
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
logf("notification failed (%s): HTTP %d", url, resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// Send delivers msg to every enabled notification channel.
|
|
func (n *Notifier) Send(msg string) {
|
|
if n.cfg.NtfyEnabled {
|
|
url := strings.TrimRight(n.cfg.NtfyServer, "/") + "/" + n.cfg.NtfyTopic
|
|
n.post(url, "text/plain", []byte(msg))
|
|
}
|
|
if n.cfg.DiscordEnabled {
|
|
body, _ := json.Marshal(map[string]string{"content": msg})
|
|
n.post(n.cfg.DiscordWebhookURL, "application/json", body)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSH / SCP helpers (shell out to the system OpenSSH client)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Remote struct{ cfg *Config }
|
|
|
|
func (r Remote) target() string { return r.cfg.SSHUser + "@" + r.cfg.SSHHost }
|
|
|
|
// run executes a command on the Home Assistant host and returns its stdout.
|
|
// stderr is passed through to the terminal (and the log file).
|
|
func (r Remote) run(remoteCmd string) (string, error) {
|
|
cmd := exec.Command("ssh", "-i", r.cfg.SSHKey, "-p", r.cfg.SSHPort, r.target(), remoteCmd)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = stderrWriter()
|
|
err := cmd.Run()
|
|
return out.String(), err
|
|
}
|
|
|
|
func (r Remote) copyFrom(remotePath, localPath string) error {
|
|
cmd := exec.Command("scp", "-i", r.cfg.SSHKey, "-P", r.cfg.SSHPort,
|
|
r.target()+":"+remotePath, localPath)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = stderrWriter()
|
|
return cmd.Run()
|
|
}
|
|
|
|
// availableKB returns free space (in KB) on the remote root filesystem.
|
|
func (r Remote) availableKB() (int64, error) {
|
|
out, err := r.run("df -k / | tail -n 1")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
fields := strings.Fields(out)
|
|
if len(fields) < 4 {
|
|
return 0, fmt.Errorf("unexpected df output: %q", out)
|
|
}
|
|
return strconv.ParseInt(fields[3], 10, 64)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Home Assistant operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var slugRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
|
|
type backupEntry struct {
|
|
Slug string `json:"slug"`
|
|
Date string `json:"date"`
|
|
}
|
|
|
|
type backupListResp struct {
|
|
Backups []backupEntry `json:"backups"`
|
|
Data struct {
|
|
Backups []backupEntry `json:"backups"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func (r Remote) listBackups() ([]backupEntry, error) {
|
|
out, err := r.run("ha backups --raw-json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp backupListResp
|
|
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
|
return nil, fmt.Errorf("could not parse 'ha backups' output as JSON: %w", err)
|
|
}
|
|
if len(resp.Data.Backups) > 0 {
|
|
return resp.Data.Backups, nil
|
|
}
|
|
return resp.Backups, nil
|
|
}
|
|
|
|
func removeOldRemoteBackups(r Remote, n *Notifier, days int) {
|
|
logf("Removing backups older than %d days on Home Assistant server...", days)
|
|
|
|
backups, err := r.listBackups()
|
|
if err != nil {
|
|
logf("Error: %v", err)
|
|
return
|
|
}
|
|
|
|
logf("Found %d backup(s) on Home Assistant server.", len(backups))
|
|
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
|
removed := 0
|
|
for _, b := range backups {
|
|
t, err := time.Parse(time.RFC3339Nano, b.Date)
|
|
if err != nil {
|
|
logf("Skipping backup %s: cannot parse date %q", b.Slug, b.Date)
|
|
continue
|
|
}
|
|
if !t.Before(cutoff) {
|
|
continue
|
|
}
|
|
if !slugRe.MatchString(b.Slug) {
|
|
logf("Skipping backup with unexpected slug %q", b.Slug)
|
|
continue
|
|
}
|
|
logf("Removing backup with slug: %s", b.Slug)
|
|
if _, err := r.run("ha backups remove " + b.Slug); err != nil {
|
|
logf("Failed to remove backup with slug: %s", b.Slug)
|
|
continue
|
|
}
|
|
removed++
|
|
logf("Successfully removed backup with slug: %s", b.Slug)
|
|
n.Send("Old backup removed: " + b.Slug)
|
|
}
|
|
if removed == 0 {
|
|
logf("No old backups found to remove (none are older than %d days).", days)
|
|
}
|
|
}
|
|
|
|
type newBackupResp struct {
|
|
Result string `json:"result"`
|
|
Message string `json:"message"`
|
|
Data struct {
|
|
Slug string `json:"slug"`
|
|
} `json:"data"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
// describeOutput turns the output of an `ha` command into a short message for
|
|
// error reports: the "message" field of a JSON response if there is one,
|
|
// otherwise the raw text (collapsed to one line and truncated).
|
|
func describeOutput(out string) string {
|
|
var resp newBackupResp
|
|
if json.Unmarshal([]byte(out), &resp) == nil && resp.Message != "" {
|
|
return resp.Message
|
|
}
|
|
text := strings.Join(strings.Fields(out), " ")
|
|
if r := []rune(text); len(r) > 300 {
|
|
text = string(r[:300]) + "..."
|
|
}
|
|
return text
|
|
}
|
|
|
|
// createBackup creates a backup and returns its slug.
|
|
func (r Remote) createBackup(name string) (string, error) {
|
|
out, err := r.run("ha backup new --name=" + name + " --raw-json")
|
|
if err != nil {
|
|
if d := describeOutput(out); d != "" {
|
|
return "", fmt.Errorf("failed to create backup on Home Assistant server: %w (Home Assistant said: %s)", err, d)
|
|
}
|
|
return "", fmt.Errorf("failed to create backup on Home Assistant server: %w", err)
|
|
}
|
|
|
|
var resp newBackupResp
|
|
if json.Unmarshal([]byte(out), &resp) == nil {
|
|
if resp.Data.Slug != "" {
|
|
return strings.TrimSpace(resp.Data.Slug), nil
|
|
}
|
|
if resp.Slug != "" {
|
|
return strings.TrimSpace(resp.Slug), nil
|
|
}
|
|
if resp.Result == "error" && resp.Message != "" {
|
|
return "", fmt.Errorf("backup creation failed: Home Assistant said: %s", resp.Message)
|
|
}
|
|
}
|
|
|
|
// Fallback: plain-text "slug: xxxx" line (what the original script parsed).
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
|
if strings.HasPrefix(line, "slug:") {
|
|
if s := strings.TrimSpace(strings.TrimPrefix(line, "slug:")); s != "" {
|
|
return s, nil
|
|
}
|
|
}
|
|
}
|
|
if d := describeOutput(out); d != "" {
|
|
return "", fmt.Errorf("failed to extract backup slug (Home Assistant said: %s)", d)
|
|
}
|
|
return "", fmt.Errorf("failed to extract backup slug")
|
|
}
|
|
|
|
// removeOldLocalBackups deletes *.tar files under dir that `find -mtime +days`
|
|
// would match (age of at least days+1 full 24h periods).
|
|
func removeOldLocalBackups(dir string, days int) error {
|
|
minAge := time.Duration(days+1) * 24 * time.Hour
|
|
now := time.Now()
|
|
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.Type().IsRegular() || !strings.HasSuffix(d.Name(), ".tar") {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if now.Sub(info.ModTime()) >= minAge {
|
|
logf("Removing old local backup: %s", path)
|
|
return os.Remove(path)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Progress reporting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 (console only, never written to the
|
|
// log file); otherwise (cron, logs) it logs 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func findConfig(flagPath string) string {
|
|
if flagPath != "" {
|
|
return flagPath
|
|
}
|
|
if exe, err := os.Executable(); err == nil {
|
|
p := filepath.Join(filepath.Dir(exe), defaultConfigName)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return defaultConfigName // current working directory
|
|
}
|
|
|
|
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 {
|
|
fmt.Printf("Error: cannot load config file %s: %v\n", confPath, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Open the log file as early as possible so validation errors are logged
|
|
// too. A failure here (e.g. not running as root) is not fatal.
|
|
if err := openLog(cfg.LogFile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: cannot open log file %s: %v (continuing without file logging)\n", cfg.LogFile, err)
|
|
}
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
logf("Error: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
notify := NewNotifier(cfg)
|
|
remote := Remote{cfg: cfg}
|
|
|
|
fail := func(msg string) {
|
|
full := "Error: " + msg
|
|
notify.Send(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)
|
|
if logFile != nil {
|
|
logf("Logging to %s", cfg.LogFile)
|
|
}
|
|
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))
|
|
lowSpace := avail < cfg.StorageThresholdKB
|
|
if lowSpace {
|
|
logf("Disk space is below the threshold. Running remote backup cleanup...")
|
|
removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays)
|
|
|
|
// Re-check: cleanup may not have freed anything.
|
|
if a, err := remote.availableKB(); err != nil {
|
|
logf("Could not re-check free space after cleanup: %v", err)
|
|
} else {
|
|
avail = a
|
|
lowSpace = avail < cfg.StorageThresholdKB
|
|
logf("Free space after cleanup: %s", humanBytes(avail*1024))
|
|
}
|
|
if lowSpace {
|
|
warn := fmt.Sprintf("Warning: low disk space on Home Assistant server (%s free, threshold %s) and cleanup did not free enough space.",
|
|
humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024))
|
|
logf("%s", warn)
|
|
notify.Send(warn)
|
|
}
|
|
} else {
|
|
logf("Sufficient disk space available. Skipping remote backup cleanup.")
|
|
}
|
|
|
|
// Create the backup.
|
|
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 {
|
|
msg := err.Error()
|
|
if lowSpace {
|
|
msg = fmt.Sprintf("not enough disk space on Home Assistant server (%s free, threshold %s); %s",
|
|
humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024), msg)
|
|
}
|
|
fail(msg + ".")
|
|
}
|
|
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))
|
|
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.")
|
|
}
|
|
|
|
info, err := os.Stat(backupFile)
|
|
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.")
|
|
|
|
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))
|
|
}
|