Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1af0a20429 | ||
|
|
0444e0d45c | ||
|
|
6b9bd80fa8 | ||
|
|
5c05a27ae2 | ||
|
|
70e1b1cc6e | ||
|
|
cab5d0cfda | ||
|
|
ce835ec488 |
No files matched your search
@@ -0,0 +1,32 @@
|
||||
### 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
|
||||
|
||||
+120
-31
@@ -1,34 +1,113 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
#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
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/home_assistant_backup.conf"
|
||||
|
||||
# Set the backup destination path
|
||||
BACKUP_DEST="BACKUP_FILE_DESTINATION" # Replace with the location for the backup files
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "Error: Config file not found: $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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"
|
||||
# Load variables
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
### Script ###
|
||||
## Sanity Check##
|
||||
# Function to check if the necessary variables are set and if jq and yq are installed
|
||||
sanity_check() {
|
||||
# Check if jq and yq are installed
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Error: jq is not installed. Please install jq before running this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo "Error: yq is not installed. Please install yq before running this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for each variable
|
||||
if [ -z "$HA_SSH_KEY" ] || [ "$HA_SSH_KEY" == "SSH_PRIVATE_KEY" ]; then
|
||||
echo "Error: SSH_PRIVATE_KEY is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$HA_SSH_USER" ] || [ "$HA_SSH_USER" == "SSH_USER" ]; then
|
||||
echo "Error: SSH_USER is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$HA_SSH_HOST" ] || [ "$HA_SSH_HOST" == "SSH_HOST_IP_OR_HOSTNAME" ]; then
|
||||
echo "Error: SSH_HOST_IP_OR_HOSTNAME is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$HA_SSH_PORT" ] || [ "$HA_SSH_PORT" == "SSH_PORT" ]; then
|
||||
echo "Error: SSH_PORT is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$BACKUP_DEST" ] || [ "$BACKUP_DEST" == "BACKUP_FILE_DESTINATION" ]; then
|
||||
echo "Error: BACKUP_FILE_DESTINATION is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NTFY_ENABLED" == "true" ]; then
|
||||
if [ -z "$NTFY_SERVER" ] || [ "$NTFY_SERVER" == "NTFY_SERVER_FQDN_ADDRESS" ]; then
|
||||
echo "Error: NTFY_SERVER_FQDN_ADDRESS is not set."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$NTFY_TOPIC" ] || [ "$NTFY_TOPIC" == "NTFY_TOPIC" ]; then
|
||||
echo "Error: NTFY_TOPIC is not set."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$DISCORD_ENABLED" == "true" ]; then
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ] || [ "$DISCORD_WEBHOOK_URL" == "DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "Error: DISCORD_WEBHOOK_URL is not set."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Call the function to check requirements and variables
|
||||
sanity_check
|
||||
|
||||
# Function to remove old backups from Home Assistant server (older than $REMOTE_BACKUP_CLEANUP days)
|
||||
remove_old_backups_ssh() {
|
||||
echo "Removing backups older than $REMOTE_BACKUP_CLEANUP days on Home Assistant server..."
|
||||
|
||||
# Fetch the list of backups and parse the YAML output using yq
|
||||
BACKUPS_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backups")
|
||||
if ! echo "$BACKUPS_OUTPUT" | yq eval >/dev/null 2>&1; then
|
||||
echo "Error: The output of 'ha backups' is not valid YAML."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Convert the YAML to JSON and extract old backups using jq
|
||||
OLD_BACKUPS=$(echo "$BACKUPS_OUTPUT" | yq eval -o=json | jq --argjson days "$REMOTE_BACKUP_CLEANUP" -r '.backups[] | select((.date | sub("\\.[0-9]+\\+00:00$"; "Z") | fromdateiso8601) < (now - ($days * 86400))) | .slug')
|
||||
|
||||
# Remove each old backup by its slug
|
||||
if [ -n "$OLD_BACKUPS" ]; then
|
||||
for slug in $OLD_BACKUPS; do
|
||||
echo "Removing backup with slug: $slug"
|
||||
ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backups remove $slug"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Successfully removed backup with slug: $slug"
|
||||
send_ntfy_notification "Old backup removed: $slug"
|
||||
send_discord_notification "Old backup removed: $slug"
|
||||
else
|
||||
echo "Failed to remove backup with slug: $slug"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "No old backups found to remove."
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
### Function for sending notifications ###
|
||||
send_ntfy_notification() {
|
||||
@@ -58,6 +137,16 @@ START_TIME=$(date +%s)
|
||||
send_ntfy_notification "Backup process started at $TIMESTAMP"
|
||||
send_discord_notification "Backup process started at $TIMESTAMP"
|
||||
|
||||
# Check available disk space on Home Assistant server
|
||||
AVAILABLE_SPACE=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "df -k / | tail -n 1 | tr -s ' ' | cut -d ' ' -f 4")
|
||||
if [ "$AVAILABLE_SPACE" -lt "$STORAGE_THRESHOLD_KB" ]; then
|
||||
echo "Disk space is less than 2GB on Home Assistant server. Proceeding with remote backup cleanup..."
|
||||
# Remove old backups on Home Assistant server if less than 2GB available
|
||||
remove_old_backups_ssh
|
||||
else
|
||||
echo "Sufficient disk space available on Home Assistant server. Skipping remote backup cleanup."
|
||||
fi
|
||||
|
||||
# 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
|
||||
@@ -104,14 +193,13 @@ if [ $? -ne 0 ]; then
|
||||
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 {} \;
|
||||
# Clean up old backups locally (older than $LOCAL_BACKUP_CLEANUP days)
|
||||
find "$BACKUP_DEST" -name '*.tar' -mtime +$LOCAL_BACKUP_CLEANUP -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."
|
||||
send_ntfy_notification "Error: Failed to clean up old backups locally."
|
||||
send_discord_notification "Error: Failed to clean up old backups locally."
|
||||
echo "Error: Failed to clean up old backups locally."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -131,3 +219,4 @@ 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,54 @@
|
||||
# ha-backup v3.2.0
|
||||
|
||||
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.
|
||||
@@ -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"
|
||||
+730
@@ -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