## 3.2.0
### Added - File logging to `/var/log/ha-backup.log`. Each line has a full date and time, and the file is appended to on every run. - New `LOG_FILE` setting in `home_assistant_backup.conf`. Set it to a custom path, or to `"none"` to disable file logging. - `ssh`/`scp` error output is also written to the log file. - Deletions of old local backups are now logged. ### Changed - All console messages now go through the same logger, so remote cleanup, notification failures and config validation errors appear in the log too. - If the log file can't be opened (for example, no write permission to `/var/log`), a warning is printed and the backup continues without file logging. - The terminal progress bar stays on the console only and is not written to the log.
This commit is contained in:
1 parent
5c05a27ae2
commit
6b9bd80fa8
3 files changed
+135
-30
No files matched your search
+33
-1
@@ -15,8 +15,40 @@ 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
|
||||
|
||||
|
||||
## Logging
|
||||
|
||||
ha-backup writes a log to `/var/log/ha-backup.log` by default. Each line is timestamped, and the file is appended to on every run. The same output is still printed to the console.
|
||||
|
||||
### Create the log file
|
||||
|
||||
Writing to `/var/log` requires elevated permissions, so create the file once and give ownership to the user that runs the backup. Replace `backupuser` with that user (use `root` if you run it as root):
|
||||
|
||||
|
||||
If you'd rather do it in steps:
|
||||
|
||||
```
|
||||
sudo touch /var/log/ha-backup.log
|
||||
sudo chown backupuser:backupuser /var/log/ha-backup.log
|
||||
sudo chmod 640 /var/log/ha-backup.log
|
||||
```
|
||||
|
||||
If the log file can't be opened, ha-backup prints a warning and continues the backup without file logging.
|
||||
|
||||
### Change or disable the log location
|
||||
|
||||
Set `LOG_FILE` in `home_assistant_backup.conf`:
|
||||
|
||||
```bash
|
||||
LOG_FILE="/var/log/ha-backup.log" # default
|
||||
LOG_FILE="/home/backupuser/ha-backup.log" # custom path
|
||||
LOG_FILE="none" # disable file logging
|
||||
```
|
||||
|
||||
|
||||
## Run
|
||||
./ha-backup # looks for home_assistant_backup.conf next to the binary, then in cwd
|
||||
./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.
|
||||
@@ -30,3 +30,9 @@ NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic
|
||||
DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications
|
||||
DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook URL
|
||||
|
||||
|
||||
## Logging
|
||||
# Set the log file location (Default /var/log/ha-backup.log)
|
||||
# The file must be writable by the user running ha-backup, see the README
|
||||
# Set to "none" to disable logging to a file (console output is unaffected)
|
||||
LOG_FILE="/var/log/ha-backup.log"
|
||||
+96
-29
@@ -1,6 +1,6 @@
|
||||
// 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.
|
||||
// ha-backup v3.2.0: 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.
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -29,10 +31,13 @@ import (
|
||||
|
||||
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.1.2"
|
||||
var version = "3.1.1"
|
||||
// go build -ldflags "-X main.version=3.2.0"
|
||||
var version = "3.2.0"
|
||||
|
||||
type Config struct {
|
||||
SSHKey string
|
||||
@@ -46,6 +51,10 @@ type Config struct {
|
||||
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
|
||||
@@ -120,6 +129,7 @@ func loadConfig(path string) (*Config, error) {
|
||||
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", ""),
|
||||
@@ -170,6 +180,56 @@ func (c *Config) validate() error {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -186,12 +246,12 @@ func NewNotifier(cfg *Config) *Notifier {
|
||||
func (n *Notifier) post(url, contentType string, body []byte) {
|
||||
resp, err := n.client.Post(url, contentType, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "notification failed (%s): %v\n", url, err)
|
||||
logf("notification failed (%s): %v", url, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
fmt.Fprintf(os.Stderr, "notification failed (%s): HTTP %d\n", url, resp.StatusCode)
|
||||
logf("notification failed (%s): HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,12 +276,12 @@ 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.
|
||||
// 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 = os.Stderr
|
||||
cmd.Stderr = stderrWriter()
|
||||
err := cmd.Run()
|
||||
return out.String(), err
|
||||
}
|
||||
@@ -230,7 +290,7 @@ 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 = os.Stderr
|
||||
cmd.Stderr = stderrWriter()
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
@@ -281,11 +341,11 @@ func (r Remote) listBackups() ([]backupEntry, error) {
|
||||
}
|
||||
|
||||
func removeOldRemoteBackups(r Remote, n *Notifier, days int) {
|
||||
fmt.Printf("Removing backups older than %d days on Home Assistant server...\n", days)
|
||||
logf("Removing backups older than %d days on Home Assistant server...", days)
|
||||
|
||||
backups, err := r.listBackups()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
logf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,27 +354,27 @@ func removeOldRemoteBackups(r Remote, n *Notifier, days int) {
|
||||
for _, b := range backups {
|
||||
t, err := time.Parse(time.RFC3339Nano, b.Date)
|
||||
if err != nil {
|
||||
fmt.Printf("Skipping backup %s: cannot parse date %q\n", b.Slug, b.Date)
|
||||
logf("Skipping backup %s: cannot parse date %q", b.Slug, b.Date)
|
||||
continue
|
||||
}
|
||||
if !t.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
if !slugRe.MatchString(b.Slug) {
|
||||
fmt.Printf("Skipping backup with unexpected slug %q\n", b.Slug)
|
||||
logf("Skipping backup with unexpected slug %q", b.Slug)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Removing backup with slug: %s\n", b.Slug)
|
||||
logf("Removing backup with slug: %s", b.Slug)
|
||||
if _, err := r.run("ha backups remove " + b.Slug); err != nil {
|
||||
fmt.Printf("Failed to remove backup with slug: %s\n", b.Slug)
|
||||
logf("Failed to remove backup with slug: %s", b.Slug)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
fmt.Printf("Successfully removed backup with slug: %s\n", b.Slug)
|
||||
logf("Successfully removed backup with slug: %s", b.Slug)
|
||||
n.Send("Old backup removed: " + b.Slug)
|
||||
}
|
||||
if removed == 0 {
|
||||
fmt.Println("No old backups found to remove.")
|
||||
logf("No old backups found to remove.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,24 +431,17 @@ func removeOldLocalBackups(dir string, days int) error {
|
||||
return err
|
||||
}
|
||||
if now.Sub(info.ModTime()) >= minAge {
|
||||
logf("Removing old local backup: %s", path)
|
||||
return os.Remove(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
@@ -444,8 +497,8 @@ func (r Remote) remoteSize(path string) int64 {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -505,6 +558,10 @@ func (r Remote) copyWithProgress(remotePath, localPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func findConfig(flagPath string) string {
|
||||
if flagPath != "" {
|
||||
return flagPath
|
||||
@@ -534,8 +591,15 @@ func main() {
|
||||
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 {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
logf("Error: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -553,6 +617,9 @@ func main() {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user