Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d45c309110 | ||
|
|
caae2fb707 | ||
|
|
1af0a20429 | ||
|
|
0444e0d45c | ||
|
|
6b9bd80fa8 | ||
|
|
5c05a27ae2 | ||
|
|
70e1b1cc6e | ||
|
|
cab5d0cfda | ||
|
|
ce835ec488 | ||
|
|
4ca301bb8a | ||
|
|
686eed2422 | ||
|
|
b1d764f9b0 | ||
|
|
400955fdb5 |
No files matched your search
@@ -1,3 +1,110 @@
|
||||
# Home-Assistant-Backup-Script
|
||||
# Home Assistant Backup Automation Scripts
|
||||
|
||||
These scripts automate the backup process for **Home Assistant** servers, enabling you to create, transfer, and manage backups. The backups are securely transferred from the Home Assistant server to your local machine, with options for cleaning up old backups based on configurable retention periods.
|
||||
|
||||
## Overview
|
||||
|
||||
### Features:
|
||||
|
||||
- **Backup Creation**: Automates the process of creating backups on your Home Assistant server.
|
||||
- **Backup Transfer**: Transfers the generated backups to a local machine for safekeeping.
|
||||
- **Old Backup Cleanup**: Automatically cleans up old backups on both the Home Assistant server and local machine after a specified retention period.
|
||||
- **Notification Support** (Optional): Configure **ntfy** or **Discord** notifications to alert you about the backup status (available in the first script version).
|
||||
|
||||
## Requirements
|
||||
|
||||
Both scripts require the following to function correctly:
|
||||
|
||||
- **Bash shell** (on your local machine and Home Assistant server)
|
||||
- **SSH** access to the Home Assistant server
|
||||
- **`scp`** and **`ssh`** tools
|
||||
- **Home Assistant** installed on the server with the `ha` CLI tool
|
||||
- **Optional**: Access to a notification service (for Version 2&3)
|
||||
|
||||
## Configuration
|
||||
|
||||
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
|
||||
|
||||
|
||||
## 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 -config /path/to.conf
|
||||
|
||||
|
||||
Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed.
|
||||
|
||||
## Three Versions of the Script:
|
||||
|
||||
This project used to contain three versions of this project. V1 and V2 have both been moved to their own repositorys and archived as they will no longer be developed and previous releases will be kept but marked as "Pre-Release"
|
||||
|
||||
1. **Version 1: See [https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V1](https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V1)**
|
||||
|
||||
2. **Version 2: See [https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V2](https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V2)**
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**The software is provided "as-is" without any warranties or guarantees of any kind.**
|
||||
|
||||
By using this script, you acknowledge and agree that:
|
||||
|
||||
- **No Warranty**: The script is provided without any warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular purpose, or non-infringement. The entire risk arising out of the use or performance of the script remains with you.
|
||||
|
||||
- **Data Loss**: The author cannot be held responsible for any data loss, damage to your system, or any other kind of loss resulting from the use of this script. You are solely responsible for backing up your data and ensuring the integrity of your systems before using the script.
|
||||
|
||||
- **Use at Your Own Risk**: You are using this script at your own risk. The author shall not be liable for any damages, including but not limited to direct, indirect, incidental, special, consequential, or punitive damages, arising out of the use of or inability to use the script.
|
||||
|
||||
- **No Support**: The script is provided "as-is" with no formal support or maintenance. The author does not provide any guarantees of continued functionality, updates, or support.
|
||||
|
||||
- **Modifications and Redistribution**: If you modify or redistribute the script, you do so at your own risk. You are solely responsible for ensuring that any modifications or redistributed versions do not cause harm or violate any laws.
|
||||
|
||||
By using or modifying this script, you agree to indemnify and hold harmless the author from any and all claims, damages, liabilities, and expenses arising from your use or modification of the script.
|
||||
|
||||
---
|
||||
|
||||
Use of this project is subject to the terms above. If you do not agree with this disclaimer, you should not use it.
|
||||
|
||||
|
||||
A script that when ran, creates a backup of Home Assistant using the "ha backup new" command over SSH, then pulls the .tar file from the HA system on to the system that the script is running on
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
|
||||
# Set your Home Assistant SSH credentials
|
||||
HA_SSH_KEY="/home/user/.ssh/HomeAssistant-key"
|
||||
HA_SSH_USER="root"
|
||||
HA_SSH_HOST="HOME.ASSISTANT.IP.ADDRESS"
|
||||
|
||||
# Set the backup destination path
|
||||
BACKUP_DEST="/BACKUP/DESTINATION"
|
||||
|
||||
# Create a backup on the Home Assistant server and extract the slug
|
||||
BACKUP_OUTPUT=$(ssh -i "$HA_SSH_KEY" "$HA_SSH_USER@$HA_SSH_HOST" "ha backup new --name=auto_backup_$(date '+%Y-%m-%d_%H-%M-%S')")
|
||||
BACKUP_SLUG=$(echo "$BACKUP_OUTPUT" | awk '/slug:/ {print $2}')
|
||||
|
||||
# Copy the backup file from Home Assistant to your local machine
|
||||
scp -i "$HA_SSH_KEY" "$HA_SSH_USER@$HA_SSH_HOST:/backup/${BACKUP_SLUG}.tar" "$BACKUP_DEST/$(date '+%Y-%m-%d_%H-%M-%S')_${BACKUP_SLUG}.tar"
|
||||
|
||||
# Clean up old backups if needed (adjust the number of days to keep)
|
||||
find "$BACKUP_DEST" -name '*.tar' -mtime +30 -exec rm {} \;
|
||||
|
||||
echo "Backup completed successfully: $BACKUP_DEST/$(date '+%Y-%m-%d_%H-%M-%S')_${BACKUP_SLUG}.tar"
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
|
||||
### Global Settings ###
|
||||
# Set your Home Assistant SSH credentials
|
||||
HA_SSH_KEY="SSH_PRIVATE_KEY" # Replace with your SSH Private key for HomeAssistant
|
||||
HA_SSH_USER="SSH_USER" # Replace with your HomeAssistant server username (Default is root)
|
||||
HA_SSH_HOST="SSH_HOST_IP_OR_HOSTNAME" # Replace with your HomeAssistant server IP address
|
||||
HA_SSH_PORT="SSH_PORT" # Replace with your HomeAssistant server port
|
||||
|
||||
# Set the backup destination path
|
||||
BACKUP_DEST="BACKUP_FILE_DESTINATION" # Replace with the location for the backup files
|
||||
|
||||
# ntfy settings
|
||||
NTFY_ENABLED=false # Set to 'true' to enable ntfy notifications
|
||||
NTFY_SERVER="NTFY_SERVER_FQDN_ADDRESS" # Replace with your ntfy server address
|
||||
NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic
|
||||
|
||||
# Discord webhook settings
|
||||
DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications
|
||||
DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook URL
|
||||
|
||||
# Set clean up old backups (adjust the number of days to keep)
|
||||
BACKUP_FILE_CLEANUP="90" # Set the number of days that backup files will be kept for (Default 90 days)
|
||||
|
||||
# Set the location of the Home Assistant backups (Set to default location /backup)
|
||||
# If you have a custom install, using the likes of Supervised
|
||||
# this location should normally be /usr/share/hassio/backup
|
||||
BACKUP_FILE_LOCATION="/backup"
|
||||
|
||||
### Script ###
|
||||
|
||||
### Function for sending notifications ###
|
||||
send_ntfy_notification() {
|
||||
local message=$1
|
||||
if [ "$NTFY_ENABLED" = true ]; then
|
||||
curl -X POST -d "$message" $NTFY_URL
|
||||
fi
|
||||
}
|
||||
|
||||
send_discord_notification() {
|
||||
local message=$1
|
||||
if [ "$DISCORD_ENABLED" = true ]; then
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"content": "'"$message"'"}' $DISCORD_WEBHOOK_URL
|
||||
fi
|
||||
}
|
||||
|
||||
# ntfy URL setup
|
||||
NTFY_URL="$NTFY_SERVER/$NTFY_TOPIC"
|
||||
|
||||
# Timestamp variable for consistent naming
|
||||
TIMESTAMP=$(date '+%Y-%m-%d_%H-%M-%S')
|
||||
|
||||
# Start the timer
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# Send notification that backup has started
|
||||
send_ntfy_notification "Backup process started at $TIMESTAMP"
|
||||
send_discord_notification "Backup process started at $TIMESTAMP"
|
||||
|
||||
# Create a backup on the Home Assistant server and extract the slug
|
||||
BACKUP_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backup new --name=auto_backup_$TIMESTAMP")
|
||||
if [ $? -ne 0 ]; then
|
||||
# Send notification if backup creation fails
|
||||
send_ntfy_notification "Error: Failed to create backup on Home Assistant server."
|
||||
send_discord_notification "Error: Failed to create backup on Home Assistant server."
|
||||
echo "Error: Failed to create backup on Home Assistant server."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the backup slug and strip any carriage return characters
|
||||
BACKUP_SLUG=$(echo "$BACKUP_OUTPUT" | awk '/slug:/ {print $2}' | tr -d '\r')
|
||||
if [ -z "$BACKUP_SLUG" ]; then
|
||||
# Send notification if backup slug extraction fails
|
||||
send_ntfy_notification "Error: Failed to extract backup slug."
|
||||
send_discord_notification "Error: Failed to extract backup slug."
|
||||
echo "Error: Failed to extract backup slug."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Send notification that backup was created
|
||||
send_ntfy_notification "Backup created successfully with slug: $BACKUP_SLUG"
|
||||
send_discord_notification "Backup created successfully with slug: $BACKUP_SLUG"
|
||||
|
||||
# Create a filename for the backup
|
||||
BACKUP_FILE="$BACKUP_DEST/${TIMESTAMP}_${BACKUP_SLUG}.tar"
|
||||
|
||||
# Copy the backup file from Home Assistant to your local machine
|
||||
scp -i "$HA_SSH_KEY" -P "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST:$BACKUP_FILE_LOCATION/${BACKUP_SLUG}.tar" "$BACKUP_FILE"
|
||||
if [ $? -ne 0 ]; then
|
||||
# Send notification if backup copy fails
|
||||
send_ntfy_notification "Error: Failed to copy backup to local machine."
|
||||
send_discord_notification "Error: Failed to copy backup to local machine."
|
||||
echo "Error: Failed to copy backup to local machine."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the size of the backup file
|
||||
BACKUP_FILE_SIZE=$(stat -c %s "$BACKUP_FILE")
|
||||
if [ $? -ne 0 ]; then
|
||||
send_ntfy_notification "Error: Failed to get backup file size."
|
||||
send_discord_notification "Error: Failed to get backup file size."
|
||||
echo "Error: Failed to get backup file size."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Clean up old backups if needed (adjust the number of days to keep)
|
||||
find "$BACKUP_DEST" -name '*.tar' -mtime +90 -exec rm {} \;
|
||||
if [ $? -ne 0 ]; then
|
||||
# Send notification if cleanup fails
|
||||
send_ntfy_notification "Error: Failed to clean up old backups."
|
||||
send_discord_notification "Error: Failed to clean up old backups."
|
||||
echo "Error: Failed to clean up old backups."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Send notification that the cleanup was successful
|
||||
send_ntfy_notification "Old backups cleaned up successfully."
|
||||
send_discord_notification "Old backups cleaned up successfully."
|
||||
|
||||
# Success message
|
||||
echo "Backup completed successfully: $BACKUP_FILE"
|
||||
send_ntfy_notification "Backup completed successfully: $BACKUP_FILE"
|
||||
send_discord_notification "Backup completed successfully: $BACKUP_FILE"
|
||||
|
||||
# Get the total time taken by the script
|
||||
END_TIME=$(date +%s)
|
||||
RUNTIME=$((END_TIME - START_TIME))
|
||||
|
||||
# Send notification with backup file size and script runtime
|
||||
send_ntfy_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds."
|
||||
send_discord_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds."
|
||||
@@ -0,0 +1,38 @@
|
||||
### Global Settings ###
|
||||
# Set your Home Assistant SSH credentials
|
||||
HA_SSH_KEY="SSH_PRIVATE_KEY" # Replace with your SSH Private key for HomeAssistant
|
||||
HA_SSH_USER="SSH_USER" # Replace with your HomeAssistant server username (Default is root)
|
||||
HA_SSH_HOST="SSH_HOST_IP_OR_HOSTNAME" # Replace with your HomeAssistant server IP address
|
||||
HA_SSH_PORT="SSH_PORT" # Replace with your HomeAssistant server port
|
||||
|
||||
## Storage
|
||||
# Set the backup destination path
|
||||
BACKUP_DEST="BACKUP_FILE_DESTINATION" # Replace with the location for the backup files
|
||||
|
||||
# Set the location of the Home Assistant backups (Set to default location /backup)
|
||||
# If you have a custom install, using the likes of Supervised
|
||||
# this location should normally be /usr/share/hassio/backup
|
||||
BACKUP_FILE_LOCATION="/backup"
|
||||
|
||||
# Storage threshold (in KB) for triggering cleanup
|
||||
STORAGE_THRESHOLD_KB=2097152 # 2GB = 2 * 1024 * 1024 KB
|
||||
# Set clean up old backups (adjust the number of days to keep)
|
||||
LOCAL_BACKUP_CLEANUP="90" # Set the number of days that local backup files will be kept for (Default 90 days)
|
||||
REMOTE_BACKUP_CLEANUP="30" # Set the number of days that backups on the Home Assistant server will be kept for (Default 30 days)
|
||||
|
||||
## Notification Settings
|
||||
# ntfy settings
|
||||
NTFY_ENABLED=false # Set to 'true' to enable ntfy notifications
|
||||
NTFY_SERVER="NTFY_SERVER_FQDN_ADDRESS" # Replace with your ntfy server address
|
||||
NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic
|
||||
|
||||
# Discord webhook settings
|
||||
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"
|
||||
@@ -0,0 +1,730 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in new issue
Block a user