Compare commits
1 Commits
0.1.0
...
refactor-t
| Author | SHA1 | Date | |
|---|---|---|---|
| e5cd26ef16 |
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
20
README.md
20
README.md
@@ -1,6 +1,6 @@
|
||||
# BackupSidecar
|
||||
|
||||
BackupSidecar is a lightweight backup solution designed to run as a cron job in Kubernetes. It automates backups using Restic and supports both directory and PostgreSQL database backups. Optional notifications can be sent via Gotify to keep you informed of backup results.
|
||||
BackupSidecar is a lightweight backup solution designed to run as a cron job in Kubernetes. It automates backups using Restic and supports both directory and PostgreSQL database backups. Notifications are sent via Gotify to keep you informed of backup results.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -15,10 +15,9 @@ These variables apply to both directory and PostgreSQL backups.
|
||||
- **`RESTIC_REPOSITORY`** _(required)_ - The URI of the Restic repository (e.g., `rest:http://your-rest-server:8000/backup`).
|
||||
- **`RESTIC_REST_USERNAME`** _(optional)_ - The username for REST server authentication.
|
||||
- **`RESTIC_REST_PASSWORD`** _(optional)_ - The password for REST server authentication.
|
||||
- **`ENABLE_GOTIFY`** _(optional)_ - Enable Gotify notifications. Set to `true` to enable, any other value or unset disables notifications. Defaults to `true`.
|
||||
- **`GOTIFYHOST`** _(required when ENABLE_GOTIFY=true)_ - The Gotify server URL.
|
||||
- **`GOTIFYTOKEN`** _(required when ENABLE_GOTIFY=true)_ - The API token for Gotify.
|
||||
- **`GOTIFYTOPIC`** _(required when ENABLE_GOTIFY=true)_ - The topic under which backup notifications will be sent.
|
||||
- **`GOTIFYHOST`** _(required)_ - The Gotify server URL.
|
||||
- **`GOTIFYTOKEN`** _(required)_ - The API token for Gotify.
|
||||
- **`GOTIFYTOPIC`** _(required)_ - The topic under which backup notifications will be sent.
|
||||
|
||||
### Directory Backup
|
||||
|
||||
@@ -83,8 +82,6 @@ spec:
|
||||
value: "directory" # or "postgres"
|
||||
- name: SOURCEDIR
|
||||
value: "/data/source"
|
||||
- name: ENABLE_GOTIFY
|
||||
value: "true"
|
||||
- name: GOTIFYHOST
|
||||
value: "http://gotify.example.com"
|
||||
- name: GOTIFYTOKEN
|
||||
@@ -107,17 +104,10 @@ spec:
|
||||
|
||||
## Notifications
|
||||
|
||||
The script can send success or failure notifications via Gotify when enabled. To enable notifications, set `ENABLE_GOTIFY=true` and provide the required Gotify configuration variables (`GOTIFYHOST`, `GOTIFYTOKEN`, `GOTIFYTOPIC`). When notifications are disabled, backup status messages are still logged to the console.
|
||||
The script sends success or failure notifications via Gotify.
|
||||
|
||||
Example success notification:
|
||||
|
||||
```
|
||||
Backup successful. Snapshot 56ff6a909a44e01f67d2d88f9a76aa713d437809d7ed14a2361e28893f38befb: files new: 1, files changed: 0, data added: 1019 bytes in 0.277535184 sec
|
||||
```
|
||||
|
||||
When Gotify is disabled, you'll see a single message at startup indicating notifications are disabled, followed by normal backup status messages:
|
||||
|
||||
```
|
||||
2024-01-15T10:30:00 - Gotify notifications disabled. Backup status will be logged to console only.
|
||||
2024-01-15T10:30:05 - Backup successful. Snapshot 56ff6a909a44e01f67d2d88f9a76aa713d437809d7ed14a2361e28893f38befb: files new: 1, files changed: 0, data added: 1019 bytes in 0.277535184 sec
|
||||
```
|
||||
|
||||
90
bun.lock
Normal file
90
bun.lock
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "backupsidecar",
|
||||
"dependencies": {
|
||||
"env-var": "^7.5.0",
|
||||
"pino": "^9.9.0",
|
||||
"pino-pretty": "^13.1.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
|
||||
|
||||
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"env-var": ["env-var@7.5.0", "", {}, "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA=="],
|
||||
|
||||
"fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="],
|
||||
|
||||
"fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"pino": ["pino@9.9.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-zxsRIQG9HzG+jEljmvmZupOMDUQ0Jpj0yAgE28jQvvrdYTlEaiGwelJpdndMl/MBuRr70heIj83QyqJUWaU8mQ=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
|
||||
"pino-pretty": ["pino-pretty@13.1.1", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^3.0.2", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-TNNEOg0eA0u+/WuqH0MH0Xui7uqVk9D74ESOpjtebSQYbNWJk/dIxCXIxFsNfeN53JmtWqYHP2OrIZjT/CBEnA=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.0.0", "", {}, "sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "backupsidecar",
|
||||
"module": "src/main.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"env-var": "^7.5.0",
|
||||
"pino": "^9.9.0",
|
||||
"pino-pretty": "^13.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
#######################################
|
||||
# Date format for logging.
|
||||
#######################################
|
||||
LOG_DATE_FORMAT="%Y-%m-%dT%T"
|
||||
|
||||
#######################################
|
||||
# Log a message with a timestamp.
|
||||
# Arguments:
|
||||
# Message to log.
|
||||
#######################################
|
||||
log() {
|
||||
echo "$(date +"$LOG_DATE_FORMAT") - $*"
|
||||
}
|
||||
|
||||
#######################################
|
||||
# Determine backup mode from the environment only.
|
||||
# Valid values: "directory" or "postgres".
|
||||
@@ -32,7 +18,7 @@ fi
|
||||
|
||||
for cmd in "${REQUIRED_CMDS[@]}"; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
log "Error: Required command '$cmd' is not installed."
|
||||
echo "Error: Required command '$cmd' is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
@@ -40,17 +26,10 @@ done
|
||||
#######################################
|
||||
# Validate common required environment variables.
|
||||
#######################################
|
||||
# Gotify notification settings (optional).
|
||||
# Set ENABLE_GOTIFY to "true" to enable notifications, any other value or unset disables them.
|
||||
ENABLE_GOTIFY="${ENABLE_GOTIFY:-true}"
|
||||
|
||||
if [ "$ENABLE_GOTIFY" = "true" ]; then
|
||||
: "${GOTIFYHOST:?Environment variable GOTIFYHOST is not set (required when ENABLE_GOTIFY=true)}"
|
||||
: "${GOTIFYTOKEN:?Environment variable GOTIFYTOKEN is not set (required when ENABLE_GOTIFY=true)}"
|
||||
: "${GOTIFYTOPIC:?Environment variable GOTIFYTOPIC is not set (required when ENABLE_GOTIFY=true)}"
|
||||
else
|
||||
log "Gotify notifications disabled. Backup status will be logged to console only."
|
||||
fi
|
||||
# Gotify notification settings.
|
||||
: "${GOTIFYHOST:?Environment variable GOTIFYHOST is not set}"
|
||||
: "${GOTIFYTOKEN:?Environment variable GOTIFYTOKEN is not set}"
|
||||
: "${GOTIFYTOPIC:?Environment variable GOTIFYTOPIC is not set}"
|
||||
|
||||
# Restic encryption password.
|
||||
: "${RESTIC_PASSWORD:?Environment variable RESTIC_PASSWORD is not set}"
|
||||
@@ -83,11 +62,23 @@ case "$BACKUP_MODE" in
|
||||
esac
|
||||
|
||||
#######################################
|
||||
# Build the Gotify URL (only if Gotify is enabled).
|
||||
# Build the Gotify URL.
|
||||
#######################################
|
||||
if [ "$ENABLE_GOTIFY" = "true" ]; then
|
||||
GOTIFYURL="${GOTIFYHOST}/message?token=${GOTIFYTOKEN}"
|
||||
fi
|
||||
|
||||
#######################################
|
||||
# Date format for logging.
|
||||
#######################################
|
||||
LOG_DATE_FORMAT="%Y-%m-%dT%T"
|
||||
|
||||
#######################################
|
||||
# Log a message with a timestamp.
|
||||
# Arguments:
|
||||
# Message to log.
|
||||
#######################################
|
||||
log() {
|
||||
echo "$(date +"$LOG_DATE_FORMAT") - $*"
|
||||
}
|
||||
|
||||
#######################################
|
||||
# Send a notification via Gotify.
|
||||
@@ -96,13 +87,6 @@ fi
|
||||
#######################################
|
||||
send_notification() {
|
||||
local message="$1"
|
||||
|
||||
# Only send notification if Gotify is enabled
|
||||
if [ "$ENABLE_GOTIFY" != "true" ]; then
|
||||
log "$message"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! curl -s -X POST "$GOTIFYURL" -F "title=${GOTIFYTOPIC}" -F "message=${message}" >/dev/null; then
|
||||
log "Warning: Failed to send notification with message: ${message}"
|
||||
fi
|
||||
|
||||
33
src/backup.ts
Normal file
33
src/backup.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
BACKUP_MODE,
|
||||
globalLogger,
|
||||
GOTIFY_HOST,
|
||||
GOTIFY_TOKEN,
|
||||
GOTIFY_TOPIC,
|
||||
} from "./env";
|
||||
import { directoryBackup } from "./directoryBackup";
|
||||
import { postgresBackup } from "./postgresBackup";
|
||||
import { gotifyClientFactory } from "./gotify";
|
||||
import { createBackupContext, reHomeContext } from "./backupContext";
|
||||
|
||||
export default async function backup() {
|
||||
const context = createBackupContext(
|
||||
"backup",
|
||||
BACKUP_MODE,
|
||||
globalLogger,
|
||||
gotifyClientFactory(GOTIFY_HOST, GOTIFY_TOKEN, GOTIFY_TOPIC)
|
||||
);
|
||||
context.logger.debug("Starting backup");
|
||||
|
||||
switch (BACKUP_MODE) {
|
||||
case "directory":
|
||||
context.logger.debug("Starting directory backup");
|
||||
return await directoryBackup(reHomeContext(context, "directoryBackup"));
|
||||
case "postgres":
|
||||
context.logger.debug("Starting postgres backup");
|
||||
return await postgresBackup(reHomeContext(context, "postgresBackup"));
|
||||
default:
|
||||
context.logger.error("Invalid backup mode");
|
||||
throw new Error("Invalid backup mode");
|
||||
}
|
||||
}
|
||||
33
src/backupContext.ts
Normal file
33
src/backupContext.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Logger } from "pino";
|
||||
import { type NotificationClient } from "./gotify";
|
||||
|
||||
export interface BackupContext {
|
||||
logger: Logger;
|
||||
notificationClient: NotificationClient;
|
||||
resticRepository: string;
|
||||
}
|
||||
|
||||
export function createBackupContext(
|
||||
module: string,
|
||||
resticRepository: string,
|
||||
globalLogger: Logger,
|
||||
notificationClient: NotificationClient
|
||||
): BackupContext {
|
||||
const logger = globalLogger.child({ module });
|
||||
|
||||
return {
|
||||
logger,
|
||||
notificationClient,
|
||||
resticRepository,
|
||||
};
|
||||
}
|
||||
|
||||
export function reHomeContext(
|
||||
context: BackupContext,
|
||||
module: string
|
||||
): BackupContext {
|
||||
return {
|
||||
...context,
|
||||
logger: context.logger.child({ module }),
|
||||
};
|
||||
}
|
||||
100
src/backupUtils.ts
Normal file
100
src/backupUtils.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { BackupContext } from "./backupContext";
|
||||
|
||||
export function parseResticSummary(output: string): string | null {
|
||||
try {
|
||||
const lines = output.split("\n").filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.message_type === "summary") {
|
||||
return `Snapshot ${parsed.snapshot_id || "none"}: files new: ${
|
||||
parsed.files_new || 0
|
||||
}, files changed: ${parsed.files_changed || 0}, data added: ${
|
||||
parsed.data_added || 0
|
||||
} bytes in ${parsed.total_duration || 0} sec`;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse restic output: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function runResticBackup(
|
||||
sourceDir: string,
|
||||
context: BackupContext
|
||||
): { success: boolean; output: string; summary: string | null } {
|
||||
const { logger, resticRepository } = context;
|
||||
|
||||
logger.info(
|
||||
`Starting backup of '${sourceDir}' to repository ${resticRepository}`
|
||||
);
|
||||
|
||||
const result = Bun.spawnSync(
|
||||
[
|
||||
"restic",
|
||||
"-r",
|
||||
resticRepository,
|
||||
"backup",
|
||||
"--no-cache",
|
||||
"--json",
|
||||
"--verbose",
|
||||
".",
|
||||
],
|
||||
{
|
||||
cwd: sourceDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
const output = result.stdout?.toString() + result.stderr?.toString() || "";
|
||||
const success = result.success;
|
||||
const summary = parseResticSummary(output);
|
||||
|
||||
return { success, output, summary };
|
||||
}
|
||||
|
||||
export async function executeBackup(
|
||||
backupType: string,
|
||||
backupFn: () => Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
summary: string | null;
|
||||
}>,
|
||||
context: BackupContext
|
||||
): Promise<void> {
|
||||
const { logger, notificationClient } = context;
|
||||
|
||||
try {
|
||||
logger.info(`Starting ${backupType} backup process`);
|
||||
|
||||
const { success, output, summary } = await backupFn();
|
||||
|
||||
console.log(output);
|
||||
|
||||
if (success) {
|
||||
const message = `${backupType} backup successful. ${
|
||||
summary || "No summary available"
|
||||
}`;
|
||||
logger.info(message);
|
||||
await notificationClient.sendNotification(message);
|
||||
} else {
|
||||
const message = `${backupType} backup failed: ${
|
||||
summary || "Unknown error"
|
||||
}`;
|
||||
logger.error(message);
|
||||
await notificationClient.sendNotification(message);
|
||||
throw new Error(`${backupType} backup failed: ${message}`);
|
||||
}
|
||||
|
||||
logger.info(`${backupType} backup completed successfully`);
|
||||
} catch (error) {
|
||||
const errorMessage = `${backupType} backup failed: ${error}`;
|
||||
logger.error(errorMessage);
|
||||
await notificationClient.sendNotification(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
13
src/directoryBackup.ts
Normal file
13
src/directoryBackup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SOURCEDIR } from "./env";
|
||||
import { executeBackup, runResticBackup } from "./backupUtils";
|
||||
import type { BackupContext } from "./backupContext";
|
||||
|
||||
export async function directoryBackup(context: BackupContext): Promise<void> {
|
||||
await executeBackup(
|
||||
"Directory",
|
||||
async () => {
|
||||
return runResticBackup(SOURCEDIR, context);
|
||||
},
|
||||
context
|
||||
);
|
||||
}
|
||||
61
src/env.ts
Normal file
61
src/env.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { from } from "env-var";
|
||||
import pino from "pino";
|
||||
|
||||
const initialEnv = from(process.env, {});
|
||||
const LOG_LEVEL = initialEnv
|
||||
.get("LOG_LEVEL")
|
||||
.default("info")
|
||||
.asEnum(["fatal", "error", "warn", "info", "debug", "trace"]);
|
||||
|
||||
export const globalLogger = pino({
|
||||
level: LOG_LEVEL,
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const env = from(process.env, {}, (msg: string) => globalLogger.info(msg));
|
||||
|
||||
export const BACKUP_MODE = env
|
||||
.get("BACKUP_MODE")
|
||||
.required()
|
||||
.asEnum(["directory", "postgres"]);
|
||||
|
||||
export const GOTIFY_HOST = env.get("GOTIFY_HOST").required().asUrlString();
|
||||
export const GOTIFY_TOKEN = env.get("GOTIFY_TOKEN").required().asString();
|
||||
export const GOTIFY_TOPIC = env.get("GOTIFY_TOPIC").required().asString();
|
||||
|
||||
export const RESTIC_PASSWORD = env.get("RESTIC_PASSWORD").required().asString();
|
||||
export const RESTIC_REPOSITORY = env
|
||||
.get("RESTIC_REPOSITORY")
|
||||
.required()
|
||||
.asString();
|
||||
export const RESTIC_REST_USERNAME = env.get("RESTIC_REST_USERNAME").asString();
|
||||
export const RESTIC_REST_PASSWORD = env.get("RESTIC_REST_PASSWORD").asString();
|
||||
|
||||
export const SOURCEDIR = env
|
||||
.get("SOURCEDIR")
|
||||
.required(BACKUP_MODE === "directory")
|
||||
.asString();
|
||||
|
||||
export const PGDATABASE = env
|
||||
.get("PGDATABASE")
|
||||
.required(BACKUP_MODE === "postgres")
|
||||
.asString();
|
||||
export const PGHOST = env
|
||||
.get("PGHOST")
|
||||
.required(BACKUP_MODE === "postgres")
|
||||
.asString();
|
||||
export const PGUSER = env
|
||||
.get("PGUSER")
|
||||
.required(BACKUP_MODE === "postgres")
|
||||
.asString();
|
||||
export const PGPORT = env
|
||||
.get("PGPORT")
|
||||
.required(BACKUP_MODE === "postgres")
|
||||
.asString();
|
||||
23
src/gotify.ts
Normal file
23
src/gotify.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface NotificationClient {
|
||||
sendNotification(message: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function gotifyClientFactory(
|
||||
gotifyHost: string,
|
||||
gotifyToken: string,
|
||||
gotifyTopic: string
|
||||
): NotificationClient {
|
||||
const sendNotification = async (message: string) => {
|
||||
await fetch(`${gotifyHost}/message?token=${gotifyToken}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: gotifyTopic,
|
||||
message: message,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
sendNotification,
|
||||
};
|
||||
}
|
||||
15
src/main.ts
Normal file
15
src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { globalLogger } from "./env";
|
||||
import backup from "./backup";
|
||||
|
||||
const logger = globalLogger.child({ module: "main" });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
logger.info("Starting backup application");
|
||||
await backup();
|
||||
logger.info("Backup application completed successfully");
|
||||
} catch (error) {
|
||||
logger.error(`Backup application failed: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
78
src/postgresBackup.ts
Normal file
78
src/postgresBackup.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { writeFileSync, mkdtempSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { PGHOST, PGDATABASE, PGUSER, PGPORT } from "./env";
|
||||
import { executeBackup, runResticBackup } from "./backupUtils";
|
||||
import type { BackupContext } from "./backupContext";
|
||||
|
||||
function dumpPostgresDatabase(context: BackupContext): {
|
||||
success: boolean;
|
||||
tempDir: string;
|
||||
dumpFile: string;
|
||||
} {
|
||||
const { logger } = context;
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "postgres-backup-"));
|
||||
const dumpFile = join(tempDir, "dump.sql");
|
||||
|
||||
logger.info(`Created temporary directory: ${tempDir}`);
|
||||
logger.info(`Dumping PostgreSQL database to ${dumpFile}...`);
|
||||
|
||||
const result = Bun.spawnSync(
|
||||
["pg_dump", "-h", PGHOST, "-p", PGPORT, "-U", PGUSER, PGDATABASE],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
writeFileSync(dumpFile, result.stdout?.toString() || "");
|
||||
logger.info("Database dump created successfully.");
|
||||
return { success: true, tempDir, dumpFile };
|
||||
} else {
|
||||
logger.error(`PostgreSQL dump failed`);
|
||||
logger.error(`stderr: ${result.stderr?.toString() || ""}`);
|
||||
return { success: false, tempDir, dumpFile };
|
||||
}
|
||||
}
|
||||
|
||||
export async function postgresBackup(context: BackupContext): Promise<void> {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
try {
|
||||
context.logger.info(
|
||||
`Starting PostgreSQL backup for database '${PGDATABASE}' on host '${PGHOST}'`
|
||||
);
|
||||
|
||||
const { success, tempDir: dir, dumpFile } = dumpPostgresDatabase(context);
|
||||
tempDir = dir;
|
||||
|
||||
if (!success) {
|
||||
throw new Error("PostgreSQL dump failed");
|
||||
}
|
||||
|
||||
await executeBackup(
|
||||
"PostgreSQL",
|
||||
async () => {
|
||||
if (!tempDir) {
|
||||
throw new Error("Temporary directory not created");
|
||||
}
|
||||
return runResticBackup(tempDir, context);
|
||||
},
|
||||
context
|
||||
);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
context.logger.info(`Removed temporary directory ${tempDir}`);
|
||||
} catch (cleanupError) {
|
||||
context.logger.warn(
|
||||
`Failed to cleanup temporary directory ${tempDir}: ${cleanupError}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user