initial commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// validateBackupTarget checks that a backup target is well-formed enough to
|
||||
// store: a known method, and (if set) a parseable cron schedule.
|
||||
func validateBackupTarget(t BackupTarget) error {
|
||||
switch t.Method {
|
||||
case MethodRsync, MethodRestic, MethodExternal:
|
||||
default:
|
||||
return fmt.Errorf("unknown backup method: %s", t.Method)
|
||||
}
|
||||
if t.Schedule != "" {
|
||||
if _, err := cron.ParseStandard(t.Schedule); err != nil {
|
||||
return fmt.Errorf("invalid schedule %q: %w", t.Schedule, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunBackup executes backup for all targets against sourcePath and returns one
|
||||
// execution record per target. scope identifies where progress is published
|
||||
// ("node" or "svc:<serviceID>").
|
||||
func RunBackup(scope string, targets []BackupTarget, sourcePath string) []BackupExecution {
|
||||
results := make([]BackupExecution, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
results = append(results, runTarget(scope, t, sourcePath))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func runTarget(scope string, t BackupTarget, sourcePath string) BackupExecution {
|
||||
start := time.Now()
|
||||
ex := BackupExecution{
|
||||
ID: fmt.Sprintf("bex_%d", start.UnixNano()),
|
||||
Timestamp: start.UTC(),
|
||||
TargetID: t.ID,
|
||||
}
|
||||
key := progressKey(scope, t.ID)
|
||||
startedAt := start.UTC()
|
||||
setProgress(key, RunState{Running: true, TargetID: t.ID, Method: t.Method, StartedAt: &startedAt})
|
||||
|
||||
if t.Method == MethodExternal {
|
||||
ex.Success = true
|
||||
ex.Message = "external backup managed externally; no action taken by API"
|
||||
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Percent: 100, Message: ex.Message, StartedAt: &startedAt})
|
||||
return ex
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch t.Method {
|
||||
case MethodRsync:
|
||||
args := []string{"-avz", "--info=progress2"}
|
||||
for k, v := range t.Params {
|
||||
args = append(args, "--"+k+"="+v)
|
||||
}
|
||||
args = append(args, sourcePath, t.Remote)
|
||||
cmd = exec.Command("rsync", args...)
|
||||
case MethodRestic:
|
||||
password := GetResticPassword()
|
||||
if password == "" {
|
||||
ex.Success = false
|
||||
ex.Message = "no restic repository password configured on this node; set restic_password via PUT /v1/node"
|
||||
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Message: ex.Message, StartedAt: &startedAt})
|
||||
return ex
|
||||
}
|
||||
env := append(os.Environ(), "RESTIC_REPOSITORY="+t.Remote, "RESTIC_PASSWORD="+password)
|
||||
if err := ensureResticRepo(env); err != nil {
|
||||
ex.Success = false
|
||||
ex.Message = fmt.Sprintf("restic init failed: %v", err)
|
||||
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Message: ex.Message, StartedAt: &startedAt})
|
||||
return ex
|
||||
}
|
||||
args := []string{"backup", sourcePath, "--json"}
|
||||
for k, v := range t.Params {
|
||||
args = append(args, "--"+k+"="+v)
|
||||
}
|
||||
cmd = exec.Command("restic", args...)
|
||||
cmd.Env = env
|
||||
default:
|
||||
ex.Success = false
|
||||
ex.Message = fmt.Sprintf("unknown backup method: %s", t.Method)
|
||||
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Message: ex.Message, StartedAt: &startedAt})
|
||||
return ex
|
||||
}
|
||||
|
||||
pw := &progressWriter{key: key, targetID: t.ID, method: t.Method, startedAt: startedAt}
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
|
||||
err := cmd.Run()
|
||||
ex.Duration = int64(time.Since(start).Seconds())
|
||||
out := strings.TrimSpace(pw.buf.String())
|
||||
if err != nil {
|
||||
ex.Success = false
|
||||
ex.Message = fmt.Sprintf("%v | %s", err, out)
|
||||
} else {
|
||||
ex.Success = true
|
||||
if pw.lastMessage != "" {
|
||||
ex.Message = pw.lastMessage
|
||||
} else {
|
||||
ex.Message = out
|
||||
}
|
||||
}
|
||||
|
||||
final := pw.state()
|
||||
final.Running = false
|
||||
if ex.Success {
|
||||
final.Percent = 100
|
||||
}
|
||||
final.Message = ex.Message
|
||||
setProgress(key, final)
|
||||
|
||||
return ex
|
||||
}
|
||||
|
||||
// ensureResticRepo initializes the restic repository named by env's
|
||||
// RESTIC_REPOSITORY on first use. Running "init" against a repository that
|
||||
// already exists fails with a message containing "already"; that case is
|
||||
// treated as success so callers don't need to track init state themselves.
|
||||
func ensureResticRepo(env []string) error {
|
||||
cmd := exec.Command("restic", "init")
|
||||
cmd.Env = env
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(string(out)), "already") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// progressWriter is assigned as both Stdout and Stderr of a backup command. It
|
||||
// buffers the full output (for the final BackupExecution.Message) while also
|
||||
// scanning completed lines for progress info to publish via setProgress.
|
||||
// Best-effort only: a line that doesn't parse just leaves the last known
|
||||
// percentage/ETA in place rather than failing the backup.
|
||||
type progressWriter struct {
|
||||
buf bytes.Buffer
|
||||
pending []byte
|
||||
key string
|
||||
targetID string
|
||||
method BackupMethod
|
||||
startedAt time.Time
|
||||
percent float64
|
||||
eta string
|
||||
lastMessage string
|
||||
}
|
||||
|
||||
func (w *progressWriter) Write(p []byte) (int, error) {
|
||||
w.buf.Write(p)
|
||||
w.pending = append(w.pending, p...)
|
||||
for {
|
||||
idx := bytes.IndexAny(w.pending, "\r\n")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
line := bytes.TrimSpace(w.pending[:idx])
|
||||
w.pending = w.pending[idx+1:]
|
||||
if len(line) > 0 {
|
||||
w.handleLine(line)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *progressWriter) handleLine(line []byte) {
|
||||
switch w.method {
|
||||
case MethodRsync:
|
||||
w.handleRsyncLine(line)
|
||||
case MethodRestic:
|
||||
w.handleResticLine(line)
|
||||
}
|
||||
setProgress(w.key, w.state())
|
||||
}
|
||||
|
||||
var rsyncPercentRe = regexp.MustCompile(`(\d+)%`)
|
||||
|
||||
func (w *progressWriter) handleRsyncLine(line []byte) {
|
||||
w.lastMessage = string(line)
|
||||
m := rsyncPercentRe.FindSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var pct float64
|
||||
if _, err := fmt.Sscanf(string(m[1]), "%f", &pct); err == nil {
|
||||
w.percent = pct
|
||||
}
|
||||
}
|
||||
|
||||
// resticStatusLine models the subset of restic's `backup --json` line kinds
|
||||
// (message_type "status", "summary", "error") that are useful for progress
|
||||
// reporting; unrecognized/unparseable lines are ignored.
|
||||
type resticStatusLine struct {
|
||||
MessageType string `json:"message_type"`
|
||||
PercentDone float64 `json:"percent_done"`
|
||||
SecondsRemaining float64 `json:"seconds_remaining"`
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (w *progressWriter) handleResticLine(line []byte) {
|
||||
var sl resticStatusLine
|
||||
if err := json.Unmarshal(line, &sl); err != nil {
|
||||
return
|
||||
}
|
||||
switch sl.MessageType {
|
||||
case "status":
|
||||
w.percent = sl.PercentDone * 100
|
||||
if sl.SecondsRemaining > 0 {
|
||||
w.eta = fmt.Sprintf("%.0fs", sl.SecondsRemaining)
|
||||
}
|
||||
case "summary":
|
||||
w.percent = 100
|
||||
if sl.SnapshotID != "" {
|
||||
w.lastMessage = fmt.Sprintf("snapshot %s created", sl.SnapshotID)
|
||||
}
|
||||
case "error":
|
||||
if sl.Error != nil && sl.Error.Message != "" {
|
||||
w.lastMessage = sl.Error.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *progressWriter) state() RunState {
|
||||
startedAt := w.startedAt
|
||||
return RunState{
|
||||
Running: true,
|
||||
TargetID: w.targetID,
|
||||
Method: w.method,
|
||||
Percent: w.percent,
|
||||
ETA: w.eta,
|
||||
Message: w.lastMessage,
|
||||
StartedAt: &startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// StartService runs the compose start command for the given service.
|
||||
func StartService(svc Service) error {
|
||||
cmd := composeCmd(svc, "start")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopService runs the compose stop command for the given service.
|
||||
func StopService(svc Service) error {
|
||||
cmd := composeCmd(svc, "stop")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func composeCmd(svc Service, action string) *exec.Cmd {
|
||||
switch svc.ComposeType {
|
||||
case "docker-compose":
|
||||
return exec.Command("docker-compose", "-f", svc.ComposeFile, action)
|
||||
case "podman-compose":
|
||||
return exec.Command("podman-compose", "-f", svc.ComposeFile, action)
|
||||
default:
|
||||
return exec.Command("docker", "compose", "-f", svc.ComposeFile, action)
|
||||
}
|
||||
}
|
||||
|
||||
// filterTargets returns the subset of targets whose ID is in ids. A nil or
|
||||
// empty ids means "all targets".
|
||||
func filterTargets(targets []BackupTarget, ids []string) []BackupTarget {
|
||||
if len(ids) == 0 {
|
||||
return targets
|
||||
}
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
result := make([]BackupTarget, 0, len(ids))
|
||||
for _, t := range targets {
|
||||
if want[t.ID] {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetLocalNode() NodeInfo {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return cfg.Node
|
||||
}
|
||||
|
||||
func UpdateLocalNode(n NodeInfo) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if n.Hostname != "" {
|
||||
cfg.Node.Hostname = n.Hostname
|
||||
}
|
||||
if n.Description != "" {
|
||||
cfg.Node.Description = n.Description
|
||||
}
|
||||
if n.ResticPassword != "" {
|
||||
cfg.Node.ResticPassword = n.ResticPassword
|
||||
}
|
||||
return persist()
|
||||
}
|
||||
|
||||
// GetResticPassword returns the node-wide restic repository password used to
|
||||
// encrypt/decrypt every restic backup target on this node.
|
||||
func GetResticPassword() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return cfg.Node.ResticPassword
|
||||
}
|
||||
|
||||
func GetUpdateHistory() []UpdateRecord {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make([]UpdateRecord, len(cfg.Node.UpdateHistory))
|
||||
copy(result, cfg.Node.UpdateHistory)
|
||||
return result
|
||||
}
|
||||
|
||||
func GetUpdateRecord(id string) (UpdateRecord, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, r := range cfg.Node.UpdateHistory {
|
||||
if r.ID == id {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return UpdateRecord{}, errors.New("update record not found")
|
||||
}
|
||||
|
||||
func AddUpdateRecord(r UpdateRecord) (UpdateRecord, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
r.ID = fmt.Sprintf("upd_%d", time.Now().UnixNano())
|
||||
r.Timestamp = time.Now().UTC()
|
||||
cfg.Node.UpdateHistory = append(cfg.Node.UpdateHistory, r)
|
||||
return r, persist()
|
||||
}
|
||||
|
||||
func DeleteUpdateRecord(id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, r := range cfg.Node.UpdateHistory {
|
||||
if r.ID == id {
|
||||
cfg.Node.UpdateHistory = append(cfg.Node.UpdateHistory[:i], cfg.Node.UpdateHistory[i+1:]...)
|
||||
return persist()
|
||||
}
|
||||
}
|
||||
return errors.New("update record not found")
|
||||
}
|
||||
|
||||
func GetNodeBackupConfig() BackupConfig {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return cfg.Node.Backup
|
||||
}
|
||||
|
||||
func UpdateNodeBackupConfig(bc BackupConfig) error {
|
||||
mu.Lock()
|
||||
history := cfg.Node.Backup.History
|
||||
cfg.Node.Backup = bc
|
||||
cfg.Node.Backup.History = history
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SyncSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
func AppendNodeBackupExecution(ex BackupExecution) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
cfg.Node.Backup.History = append(cfg.Node.Backup.History, ex)
|
||||
now := time.Now().UTC()
|
||||
cfg.Node.Backup.LastRun = &now
|
||||
for i, t := range cfg.Node.Backup.Targets {
|
||||
if t.ID == ex.TargetID {
|
||||
cfg.Node.Backup.Targets[i].LastRun = &now
|
||||
break
|
||||
}
|
||||
}
|
||||
return persist()
|
||||
}
|
||||
|
||||
// GetNodeBackupTargets lists the node's individual backup target definitions.
|
||||
func GetNodeBackupTargets() []BackupTarget {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make([]BackupTarget, len(cfg.Node.Backup.Targets))
|
||||
copy(result, cfg.Node.Backup.Targets)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetNodeBackupTarget returns a single node backup target by ID.
|
||||
func GetNodeBackupTarget(tid string) (BackupTarget, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, t := range cfg.Node.Backup.Targets {
|
||||
if t.ID == tid {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return BackupTarget{}, errors.New("backup target not found")
|
||||
}
|
||||
|
||||
// AddNodeBackupTarget creates a new backup target definition for the node.
|
||||
func AddNodeBackupTarget(t BackupTarget) (BackupTarget, error) {
|
||||
if err := validateBackupTarget(t); err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
mu.Lock()
|
||||
t.ID = fmt.Sprintf("bt_%d", time.Now().UnixNano())
|
||||
cfg.Node.Backup.Targets = append(cfg.Node.Backup.Targets, t)
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
SyncSchedule()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// UpdateNodeBackupTarget replaces an existing node backup target definition.
|
||||
func UpdateNodeBackupTarget(tid string, t BackupTarget) (BackupTarget, error) {
|
||||
if err := validateBackupTarget(t); err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
mu.Lock()
|
||||
found := false
|
||||
for i, existing := range cfg.Node.Backup.Targets {
|
||||
if existing.ID == tid {
|
||||
t.ID = tid
|
||||
t.LastRun = existing.LastRun
|
||||
cfg.Node.Backup.Targets[i] = t
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mu.Unlock()
|
||||
return BackupTarget{}, errors.New("backup target not found")
|
||||
}
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
SyncSchedule()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteNodeBackupTarget removes a node backup target definition.
|
||||
func DeleteNodeBackupTarget(tid string) error {
|
||||
mu.Lock()
|
||||
found := false
|
||||
for i, t := range cfg.Node.Backup.Targets {
|
||||
if t.ID == tid {
|
||||
cfg.Node.Backup.Targets = append(cfg.Node.Backup.Targets[:i], cfg.Node.Backup.Targets[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mu.Unlock()
|
||||
return errors.New("backup target not found")
|
||||
}
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SyncSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunNodeBackup runs the node's backup for the given target IDs (nil/empty
|
||||
// means all targets), appending an execution record per target.
|
||||
func RunNodeBackup(targetIDs []string) error {
|
||||
bc := GetNodeBackupConfig()
|
||||
targets := filterTargets(bc.Targets, targetIDs)
|
||||
if len(targets) == 0 {
|
||||
return errors.New("no matching backup targets")
|
||||
}
|
||||
sourcePath := bc.SourcePath
|
||||
if sourcePath == "" {
|
||||
sourcePath = ConfigPath()
|
||||
}
|
||||
results := RunBackup("node", targets, sourcePath)
|
||||
for _, ex := range results {
|
||||
_ = AppendNodeBackupExecution(ex)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetAllRemoteNodes() []RemoteNode {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make([]RemoteNode, len(cfg.Nodes))
|
||||
copy(result, cfg.Nodes)
|
||||
return result
|
||||
}
|
||||
|
||||
func GetRemoteNode(id string) (RemoteNode, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, n := range cfg.Nodes {
|
||||
if n.ID == id {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return RemoteNode{}, errors.New("node not found")
|
||||
}
|
||||
|
||||
func AddRemoteNode(n RemoteNode) (RemoteNode, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n.ID = fmt.Sprintf("node_%d", time.Now().UnixNano())
|
||||
cfg.Nodes = append(cfg.Nodes, n)
|
||||
return n, persist()
|
||||
}
|
||||
|
||||
func UpdateRemoteNode(id string, n RemoteNode) (RemoteNode, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, node := range cfg.Nodes {
|
||||
if node.ID == id {
|
||||
n.ID = id
|
||||
cfg.Nodes[i] = n
|
||||
return n, persist()
|
||||
}
|
||||
}
|
||||
return RemoteNode{}, errors.New("node not found")
|
||||
}
|
||||
|
||||
func DeleteRemoteNode(id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, n := range cfg.Nodes {
|
||||
if n.ID == id {
|
||||
cfg.Nodes = append(cfg.Nodes[:i], cfg.Nodes[i+1:]...)
|
||||
return persist()
|
||||
}
|
||||
}
|
||||
return errors.New("node not found")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunState is the in-memory, non-persisted status of a backup target's current
|
||||
// or most recent run. It intentionally lives outside cfg/persist(): a crash or
|
||||
// restart should never leave a stale "still running" flag behind.
|
||||
type RunState struct {
|
||||
Running bool `json:"running"`
|
||||
TargetID string `json:"target_id"`
|
||||
Method BackupMethod `json:"method,omitempty"`
|
||||
Percent float64 `json:"percent"`
|
||||
ETA string `json:"eta,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
progressMu sync.RWMutex
|
||||
progress = map[string]RunState{}
|
||||
)
|
||||
|
||||
// progressKey builds the scoped key a target's run state is stored under.
|
||||
// scope is "node" or "svc:<serviceID>".
|
||||
func progressKey(scope, targetID string) string {
|
||||
return scope + ":" + targetID
|
||||
}
|
||||
|
||||
func setProgress(key string, s RunState) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
progress[key] = s
|
||||
}
|
||||
|
||||
// GetProgress returns the current run state for a single target, if any is known.
|
||||
func GetProgress(scope, targetID string) (RunState, bool) {
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
s, ok := progress[progressKey(scope, targetID)]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// GetProgressForScope returns all known run states for a scope ("node" or "svc:<id>").
|
||||
func GetProgressForScope(scope string) []RunState {
|
||||
prefix := scope + ":"
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
result := make([]RunState, 0)
|
||||
for k, s := range progress {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var composeFileNames = []string{
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"compose.yml",
|
||||
"compose.yaml",
|
||||
}
|
||||
|
||||
// Host paths excluded from backup folder deduction.
|
||||
var excludedPrefixes = []string{"/var/run/", "/proc/", "/sys/", "/dev/", "/run/", "/etc/"}
|
||||
var excludedExact = []string{
|
||||
"/var/run/docker.sock",
|
||||
"/etc/timezone", "/etc/localtime", "/etc/hosts", "/etc/hostname", "/etc/resolv.conf",
|
||||
}
|
||||
|
||||
// ScanAll discovers compose-based services under all default paths plus an optional
|
||||
// extra folder. Existing services matched by compose file path are updated (status,
|
||||
// and backup folder if previously unset). New services are created.
|
||||
func ScanAll(extraFolder string) ScanResult {
|
||||
paths := resolveScanPaths(extraFolder)
|
||||
result := ScanResult{
|
||||
ScannedPaths: paths,
|
||||
Created: []ServiceScanInfo{},
|
||||
Updated: []ServiceScanInfo{},
|
||||
Errors: []string{},
|
||||
}
|
||||
for _, p := range paths {
|
||||
r := scanFolder(p)
|
||||
result.Found += r.Found
|
||||
result.Created = append(result.Created, r.Created...)
|
||||
result.Updated = append(result.Updated, r.Updated...)
|
||||
result.Errors = append(result.Errors, r.Errors...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveScanPaths returns the directories to scan: /opt/compose, /root/compose,
|
||||
// /home/*/compose, and any explicitly supplied extra path.
|
||||
func resolveScanPaths(extra string) []string {
|
||||
var paths []string
|
||||
for _, candidate := range []string{"/opt/compose", "/root/compose"} {
|
||||
if isAccessibleDir(candidate) {
|
||||
paths = append(paths, candidate)
|
||||
}
|
||||
}
|
||||
if entries, err := os.ReadDir("/home"); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join("/home", e.Name(), "compose")
|
||||
if isAccessibleDir(p) {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if extra != "" && isAccessibleDir(extra) {
|
||||
paths = append(paths, extra)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func isAccessibleDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// scanFolder scans the immediate subdirectories of folder for compose files.
|
||||
func scanFolder(folder string) ScanResult {
|
||||
result := ScanResult{Created: []ServiceScanInfo{}, Updated: []ServiceScanInfo{}}
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
result.Errors = []string{fmt.Sprintf("cannot read %s: %v", folder, err)}
|
||||
return result
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
serviceDir := filepath.Join(folder, entry.Name())
|
||||
composeFile := findComposeFile(serviceDir)
|
||||
if composeFile == "" {
|
||||
continue
|
||||
}
|
||||
result.Found++
|
||||
info, err := processComposeFile(entry.Name(), composeFile)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", composeFile, err))
|
||||
continue
|
||||
}
|
||||
created, isNew := upsertService(info)
|
||||
if created {
|
||||
if isNew {
|
||||
result.Created = append(result.Created, info)
|
||||
} else {
|
||||
result.Updated = append(result.Updated, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findComposeFile(dir string) string {
|
||||
for _, name := range composeFileNames {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// processComposeFile parses a compose file and produces a ServiceScanInfo.
|
||||
func processComposeFile(name, composeFile string) (ServiceScanInfo, error) {
|
||||
hostPaths, err := extractHostPaths(composeFile)
|
||||
if err != nil {
|
||||
return ServiceScanInfo{}, err
|
||||
}
|
||||
return ServiceScanInfo{
|
||||
Name: name,
|
||||
ComposeFile: composeFile,
|
||||
BackupFolder: deduceBackupFolder(hostPaths, name),
|
||||
Status: probeServiceStatus(composeFile),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractHostPaths parses all volume bind-mount host paths from a compose YAML file,
|
||||
// filtering out system/runtime paths that should not be backed up.
|
||||
// If yaml.v3 strict parsing fails (e.g. duplicate mapping keys in real-world compose
|
||||
// files), it falls back to a lenient text-based extraction.
|
||||
func extractHostPaths(composeFile string) ([]string, error) {
|
||||
data, err := os.ReadFile(composeFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var doc map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
// Malformed YAML (duplicate keys etc.) — fall back to text scan.
|
||||
return extractHostPathsFallback(data), nil
|
||||
}
|
||||
|
||||
servicesRaw, ok := doc["services"]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
services, _ := servicesRaw.(map[string]interface{})
|
||||
|
||||
seen := map[string]bool{}
|
||||
var paths []string
|
||||
|
||||
for _, svcRaw := range services {
|
||||
svc, ok := svcRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
volumes, _ := svc["volumes"].([]interface{})
|
||||
for _, v := range volumes {
|
||||
var hostPath string
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
hostPath = hostPathFromShortForm(vv)
|
||||
case map[string]interface{}:
|
||||
if t, _ := vv["type"].(string); t == "bind" {
|
||||
hostPath, _ = vv["source"].(string)
|
||||
}
|
||||
}
|
||||
hostPath = filepath.Clean(hostPath)
|
||||
if hostPath != "" && hostPath != "." && isBackupablePath(hostPath) && !seen[hostPath] {
|
||||
seen[hostPath] = true
|
||||
paths = append(paths, hostPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// extractHostPathsFallback uses simple line scanning to extract volume bind-mounts
|
||||
// from compose files that fail strict YAML parsing (duplicate keys, etc.).
|
||||
// It looks for list items that contain an absolute host path followed by a colon.
|
||||
func extractHostPathsFallback(data []byte) []string {
|
||||
seen := map[string]bool{}
|
||||
var paths []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Volume list entries: "- /abs/path:/container/path" (with or without quotes)
|
||||
if !strings.HasPrefix(trimmed, "- ") {
|
||||
continue
|
||||
}
|
||||
spec := strings.Trim(strings.TrimPrefix(trimmed, "- "), "\"' ")
|
||||
if !strings.HasPrefix(spec, "/") {
|
||||
continue
|
||||
}
|
||||
hostPath := hostPathFromShortForm(spec)
|
||||
if hostPath == "" {
|
||||
continue
|
||||
}
|
||||
hostPath = filepath.Clean(hostPath)
|
||||
if isBackupablePath(hostPath) && !seen[hostPath] {
|
||||
seen[hostPath] = true
|
||||
paths = append(paths, hostPath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// hostPathFromShortForm extracts the host path from "host:container[:opts]" volume spec.
|
||||
func hostPathFromShortForm(spec string) string {
|
||||
parts := strings.SplitN(spec, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
src := parts[0]
|
||||
if !filepath.IsAbs(src) {
|
||||
return "" // named volume or relative path
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// isBackupablePath returns false for system/runtime paths that should not be backed up.
|
||||
func isBackupablePath(path string) bool {
|
||||
for _, exact := range excludedExact {
|
||||
if path == exact {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range excludedPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// deduceBackupFolder finds the most appropriate backup root for a service.
|
||||
//
|
||||
// Priority:
|
||||
// 1. /opt/data/containers/<serviceName> — standard layout in the examples
|
||||
// 2. Longest common directory prefix across all collected host paths
|
||||
// 3. First collected path as-is
|
||||
func deduceBackupFolder(hostPaths []string, serviceName string) string {
|
||||
if len(hostPaths) == 0 {
|
||||
return ""
|
||||
}
|
||||
// 1. Standard layout
|
||||
standard := "/opt/data/containers/" + serviceName
|
||||
for _, p := range hostPaths {
|
||||
if strings.HasPrefix(p, standard) {
|
||||
return standard
|
||||
}
|
||||
}
|
||||
// 2. Common prefix
|
||||
prefix := commonPathPrefix(hostPaths)
|
||||
if prefix != "" && prefix != "/" {
|
||||
return prefix
|
||||
}
|
||||
// 3. Fallback
|
||||
return hostPaths[0]
|
||||
}
|
||||
|
||||
// commonPathPrefix returns the longest common directory prefix shared by all paths.
|
||||
func commonPathPrefix(paths []string) string {
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(paths) == 1 {
|
||||
return paths[0]
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
parts := strings.Split(paths[0], sep)
|
||||
for _, p := range paths[1:] {
|
||||
other := strings.Split(p, sep)
|
||||
limit := len(parts)
|
||||
if len(other) < limit {
|
||||
limit = len(other)
|
||||
}
|
||||
i := 0
|
||||
for i < limit && parts[i] == other[i] {
|
||||
i++
|
||||
}
|
||||
parts = parts[:i]
|
||||
}
|
||||
if len(parts) <= 1 {
|
||||
return "/"
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// probeServiceStatus checks whether the service has any running containers.
|
||||
// Uses the docker project label set by compose, then falls back to compose CLI.
|
||||
func probeServiceStatus(composeFile string) string {
|
||||
projectName := strings.ToLower(filepath.Base(filepath.Dir(composeFile)))
|
||||
|
||||
// Method 1: docker ps with project label (no compose binary required)
|
||||
cmd := exec.Command("docker", "ps", "-q",
|
||||
"--filter", "label=com.docker.compose.project="+projectName)
|
||||
if out, err := cmd.Output(); err == nil {
|
||||
if strings.TrimSpace(string(out)) != "" {
|
||||
return "up"
|
||||
}
|
||||
return "down"
|
||||
}
|
||||
|
||||
// Method 2: docker compose ps
|
||||
cmd = exec.Command("docker", "compose", "-f", composeFile, "ps", "-q")
|
||||
if out, err := cmd.Output(); err == nil {
|
||||
if strings.TrimSpace(string(out)) != "" {
|
||||
return "up"
|
||||
}
|
||||
return "down"
|
||||
}
|
||||
|
||||
// Method 3: legacy docker-compose
|
||||
cmd = exec.Command("docker-compose", "-f", composeFile, "ps", "-q")
|
||||
if out, err := cmd.Output(); err == nil {
|
||||
if strings.TrimSpace(string(out)) != "" {
|
||||
return "up"
|
||||
}
|
||||
return "down"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// upsertService creates or updates a service entry indexed by compose file path.
|
||||
// Returns (true, true) = created; (true, false) = updated; (false, _) = no change.
|
||||
func upsertService(info ServiceScanInfo) (bool, bool) {
|
||||
services := GetAllServices()
|
||||
for _, svc := range services {
|
||||
if svc.ComposeFile != info.ComposeFile {
|
||||
continue
|
||||
}
|
||||
// Found — update status and backup folder if previously unset
|
||||
changed := svc.Status != info.Status
|
||||
if svc.BackupFolder == "" && info.BackupFolder != "" {
|
||||
svc.BackupFolder = info.BackupFolder
|
||||
changed = true
|
||||
}
|
||||
svc.Status = info.Status
|
||||
if changed {
|
||||
_, _ = UpdateService(svc.ID, svc)
|
||||
return true, false
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
// Not found — create
|
||||
_, _ = AddService(Service{
|
||||
Name: info.Name,
|
||||
ComposeFile: info.ComposeFile,
|
||||
BackupFolder: info.BackupFolder,
|
||||
Status: info.Status,
|
||||
ComposeType: "docker compose",
|
||||
})
|
||||
return true, true
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
schedMu sync.Mutex
|
||||
schedCron *cron.Cron
|
||||
entryTarget = map[cron.EntryID]targetRef{}
|
||||
)
|
||||
|
||||
type targetRef struct {
|
||||
scope string // "node" or "svc:<serviceID>"
|
||||
targetID string
|
||||
}
|
||||
|
||||
// StartScheduler starts the in-process cron scheduler and performs an initial
|
||||
// sync from the current configuration. Call once from main.go before
|
||||
// beego.Run() — not from the scan/info CLI paths.
|
||||
func StartScheduler() {
|
||||
schedMu.Lock()
|
||||
schedCron = cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
|
||||
schedCron.Start()
|
||||
schedMu.Unlock()
|
||||
SyncSchedule()
|
||||
}
|
||||
|
||||
// SyncSchedule rebuilds all cron entries from the current backup target
|
||||
// definitions (node + every service). Called once at startup and after any
|
||||
// target add/update/delete so the running scheduler reflects the latest
|
||||
// definitions.
|
||||
func SyncSchedule() {
|
||||
schedMu.Lock()
|
||||
defer schedMu.Unlock()
|
||||
if schedCron == nil {
|
||||
return
|
||||
}
|
||||
for _, e := range schedCron.Entries() {
|
||||
schedCron.Remove(e.ID)
|
||||
}
|
||||
entryTarget = map[cron.EntryID]targetRef{}
|
||||
|
||||
mu.RLock()
|
||||
nodeTargets := make([]BackupTarget, len(cfg.Node.Backup.Targets))
|
||||
copy(nodeTargets, cfg.Node.Backup.Targets)
|
||||
services := make([]Service, len(cfg.Services))
|
||||
copy(services, cfg.Services)
|
||||
mu.RUnlock()
|
||||
|
||||
for _, t := range nodeTargets {
|
||||
registerJob("node", t)
|
||||
}
|
||||
for _, s := range services {
|
||||
if s.Backup == nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.Backup.Targets {
|
||||
registerJob("svc:"+s.ID, t)
|
||||
}
|
||||
}
|
||||
refreshNextRunLocked()
|
||||
}
|
||||
|
||||
// registerJob schedules a single target's cron job. Caller must hold schedMu.
|
||||
func registerJob(scope string, t BackupTarget) {
|
||||
if t.Schedule == "" {
|
||||
return
|
||||
}
|
||||
schedule, err := cron.ParseStandard(t.Schedule)
|
||||
if err != nil {
|
||||
// Already validated at CRUD time; defensively skip a bad schedule
|
||||
// rather than let it wedge the whole sync.
|
||||
return
|
||||
}
|
||||
targetID := t.ID
|
||||
job := cron.NewChain(cron.SkipIfStillRunning(cron.DefaultLogger)).Then(cron.FuncJob(func() {
|
||||
runScheduledTarget(scope, targetID)
|
||||
}))
|
||||
id := schedCron.Schedule(schedule, job)
|
||||
entryTarget[id] = targetRef{scope: scope, targetID: targetID}
|
||||
}
|
||||
|
||||
func runScheduledTarget(scope, targetID string) {
|
||||
var err error
|
||||
switch {
|
||||
case scope == "node":
|
||||
err = RunNodeBackup([]string{targetID})
|
||||
case strings.HasPrefix(scope, "svc:"):
|
||||
err = RunServiceBackup(strings.TrimPrefix(scope, "svc:"), []string{targetID})
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("nodemaster: scheduled backup %s/%s failed: %v", scope, targetID, err)
|
||||
}
|
||||
schedMu.Lock()
|
||||
refreshNextRunLocked()
|
||||
schedMu.Unlock()
|
||||
}
|
||||
|
||||
// refreshNextRunLocked writes each scheduled target's NextRun from the cron
|
||||
// entries' computed next-fire time. Caller must hold schedMu.
|
||||
func refreshNextRunLocked() {
|
||||
if schedCron == nil {
|
||||
return
|
||||
}
|
||||
updates := map[targetRef]time.Time{}
|
||||
for _, e := range schedCron.Entries() {
|
||||
if ref, ok := entryTarget[e.ID]; ok {
|
||||
updates[ref] = e.Next
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
for ref, next := range updates {
|
||||
n := next
|
||||
if ref.scope == "node" {
|
||||
for i, t := range cfg.Node.Backup.Targets {
|
||||
if t.ID == ref.targetID {
|
||||
cfg.Node.Backup.Targets[i].NextRun = &n
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
svcID := strings.TrimPrefix(ref.scope, "svc:")
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID != svcID || s.Backup == nil {
|
||||
continue
|
||||
}
|
||||
for j, t := range s.Backup.Targets {
|
||||
if t.ID == ref.targetID {
|
||||
cfg.Services[i].Backup.Targets[j].NextRun = &n
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = persist()
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetAllServices() []Service {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make([]Service, len(cfg.Services))
|
||||
copy(result, cfg.Services)
|
||||
return result
|
||||
}
|
||||
|
||||
func GetService(id string) (Service, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return Service{}, errors.New("service not found")
|
||||
}
|
||||
|
||||
func AddService(s Service) (Service, error) {
|
||||
mu.Lock()
|
||||
s.ID = fmt.Sprintf("svc_%d", time.Now().UnixNano())
|
||||
if s.Backup != nil {
|
||||
if s.Backup.Targets == nil {
|
||||
s.Backup.Targets = []BackupTarget{}
|
||||
}
|
||||
if s.Backup.History == nil {
|
||||
s.Backup.History = []BackupExecution{}
|
||||
}
|
||||
}
|
||||
cfg.Services = append(cfg.Services, s)
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
SyncSchedule()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func UpdateService(id string, s Service) (Service, error) {
|
||||
mu.Lock()
|
||||
found := false
|
||||
for i, svc := range cfg.Services {
|
||||
if svc.ID == id {
|
||||
s.ID = id
|
||||
if s.Backup != nil && svc.Backup != nil {
|
||||
s.Backup.History = svc.Backup.History
|
||||
}
|
||||
cfg.Services[i] = s
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mu.Unlock()
|
||||
return Service{}, errors.New("service not found")
|
||||
}
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
SyncSchedule()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func DeleteService(id string) error {
|
||||
mu.Lock()
|
||||
found := false
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
cfg.Services = append(cfg.Services[:i], cfg.Services[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mu.Unlock()
|
||||
return errors.New("service not found")
|
||||
}
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SyncSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateServiceStatus(id, status string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
cfg.Services[i].Status = status
|
||||
return persist()
|
||||
}
|
||||
}
|
||||
return errors.New("service not found")
|
||||
}
|
||||
|
||||
func AppendServiceBackupExecution(id string, ex BackupExecution) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
if cfg.Services[i].Backup == nil {
|
||||
cfg.Services[i].Backup = &BackupConfig{
|
||||
Targets: []BackupTarget{},
|
||||
History: []BackupExecution{},
|
||||
}
|
||||
}
|
||||
cfg.Services[i].Backup.History = append(cfg.Services[i].Backup.History, ex)
|
||||
now := time.Now().UTC()
|
||||
cfg.Services[i].Backup.LastRun = &now
|
||||
for j, t := range cfg.Services[i].Backup.Targets {
|
||||
if t.ID == ex.TargetID {
|
||||
cfg.Services[i].Backup.Targets[j].LastRun = &now
|
||||
break
|
||||
}
|
||||
}
|
||||
return persist()
|
||||
}
|
||||
}
|
||||
return errors.New("service not found")
|
||||
}
|
||||
|
||||
// GetServiceBackupTargets lists a service's individual backup target definitions.
|
||||
func GetServiceBackupTargets(id string) ([]BackupTarget, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
if s.Backup == nil {
|
||||
return []BackupTarget{}, nil
|
||||
}
|
||||
result := make([]BackupTarget, len(s.Backup.Targets))
|
||||
copy(result, s.Backup.Targets)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("service not found")
|
||||
}
|
||||
|
||||
// GetServiceBackupTarget returns a single backup target by ID for a service.
|
||||
func GetServiceBackupTarget(id, tid string) (BackupTarget, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
if s.Backup == nil {
|
||||
return BackupTarget{}, errors.New("backup target not found")
|
||||
}
|
||||
for _, t := range s.Backup.Targets {
|
||||
if t.ID == tid {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return BackupTarget{}, errors.New("backup target not found")
|
||||
}
|
||||
}
|
||||
return BackupTarget{}, errors.New("service not found")
|
||||
}
|
||||
|
||||
// AddServiceBackupTarget creates a new backup target definition for a service.
|
||||
func AddServiceBackupTarget(id string, t BackupTarget) (BackupTarget, error) {
|
||||
if err := validateBackupTarget(t); err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
mu.Lock()
|
||||
idx := -1
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
mu.Unlock()
|
||||
return BackupTarget{}, errors.New("service not found")
|
||||
}
|
||||
if cfg.Services[idx].Backup == nil {
|
||||
cfg.Services[idx].Backup = &BackupConfig{
|
||||
Targets: []BackupTarget{},
|
||||
History: []BackupExecution{},
|
||||
}
|
||||
}
|
||||
t.ID = fmt.Sprintf("bt_%d", time.Now().UnixNano())
|
||||
cfg.Services[idx].Backup.Targets = append(cfg.Services[idx].Backup.Targets, t)
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
SyncSchedule()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// UpdateServiceBackupTarget replaces an existing backup target definition for a service.
|
||||
func UpdateServiceBackupTarget(id, tid string, t BackupTarget) (BackupTarget, error) {
|
||||
if err := validateBackupTarget(t); err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
mu.Lock()
|
||||
svcIdx := -1
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
svcIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if svcIdx == -1 || cfg.Services[svcIdx].Backup == nil {
|
||||
mu.Unlock()
|
||||
return BackupTarget{}, errors.New("service not found")
|
||||
}
|
||||
found := false
|
||||
for i, existing := range cfg.Services[svcIdx].Backup.Targets {
|
||||
if existing.ID == tid {
|
||||
t.ID = tid
|
||||
t.LastRun = existing.LastRun
|
||||
cfg.Services[svcIdx].Backup.Targets[i] = t
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mu.Unlock()
|
||||
return BackupTarget{}, errors.New("backup target not found")
|
||||
}
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return BackupTarget{}, err
|
||||
}
|
||||
SyncSchedule()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteServiceBackupTarget removes a backup target definition from a service.
|
||||
func DeleteServiceBackupTarget(id, tid string) error {
|
||||
mu.Lock()
|
||||
svcIdx := -1
|
||||
for i, s := range cfg.Services {
|
||||
if s.ID == id {
|
||||
svcIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if svcIdx == -1 || cfg.Services[svcIdx].Backup == nil {
|
||||
mu.Unlock()
|
||||
return errors.New("service not found")
|
||||
}
|
||||
found := false
|
||||
for i, t := range cfg.Services[svcIdx].Backup.Targets {
|
||||
if t.ID == tid {
|
||||
cfg.Services[svcIdx].Backup.Targets = append(
|
||||
cfg.Services[svcIdx].Backup.Targets[:i],
|
||||
cfg.Services[svcIdx].Backup.Targets[i+1:]...,
|
||||
)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mu.Unlock()
|
||||
return errors.New("backup target not found")
|
||||
}
|
||||
err := persist()
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SyncSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunServiceBackup runs a service's backup for the given target IDs (nil/empty
|
||||
// means all targets), honoring StopOnBackup, and appends an execution record
|
||||
// per target.
|
||||
func RunServiceBackup(id string, targetIDs []string) error {
|
||||
svc, err := GetService(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if svc.Backup == nil || len(svc.Backup.Targets) == 0 {
|
||||
return errors.New("no backup targets configured for this service")
|
||||
}
|
||||
targets := filterTargets(svc.Backup.Targets, targetIDs)
|
||||
if len(targets) == 0 {
|
||||
return errors.New("no matching backup targets")
|
||||
}
|
||||
sourcePath := svc.BackupFolder
|
||||
if svc.Backup.SourcePath != "" {
|
||||
sourcePath = svc.Backup.SourcePath
|
||||
}
|
||||
if svc.StopOnBackup {
|
||||
_ = StopService(svc)
|
||||
}
|
||||
results := RunBackup("svc:"+id, targets, sourcePath)
|
||||
for _, ex := range results {
|
||||
_ = AppendServiceBackupExecution(id, ex)
|
||||
}
|
||||
if svc.StopOnBackup {
|
||||
_ = StartService(svc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
cfg *AppConfig
|
||||
cfgPath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
cfgPath = resolveCfgPath()
|
||||
hostname, _ := os.Hostname()
|
||||
cfg = &AppConfig{
|
||||
Node: NodeInfo{
|
||||
Hostname: hostname,
|
||||
UpdateHistory: []UpdateRecord{},
|
||||
Backup: BackupConfig{
|
||||
Targets: []BackupTarget{},
|
||||
History: []BackupExecution{},
|
||||
},
|
||||
},
|
||||
Nodes: []RemoteNode{},
|
||||
Services: []Service{},
|
||||
}
|
||||
if data, err := os.ReadFile(cfgPath); err == nil {
|
||||
_ = json.Unmarshal(data, cfg)
|
||||
}
|
||||
ensureSlices()
|
||||
}
|
||||
|
||||
func ensureSlices() {
|
||||
if cfg.Node.UpdateHistory == nil {
|
||||
cfg.Node.UpdateHistory = []UpdateRecord{}
|
||||
}
|
||||
if cfg.Node.Backup.Targets == nil {
|
||||
cfg.Node.Backup.Targets = []BackupTarget{}
|
||||
}
|
||||
if cfg.Node.Backup.History == nil {
|
||||
cfg.Node.Backup.History = []BackupExecution{}
|
||||
}
|
||||
if cfg.Nodes == nil {
|
||||
cfg.Nodes = []RemoteNode{}
|
||||
}
|
||||
if cfg.Services == nil {
|
||||
cfg.Services = []Service{}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCfgPath() string {
|
||||
const etcPath = "/etc/nodemaster/nodemaster.conf"
|
||||
if err := os.MkdirAll(filepath.Dir(etcPath), 0755); err == nil {
|
||||
if f, err := os.OpenFile(etcPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600); err == nil {
|
||||
f.Close()
|
||||
return etcPath
|
||||
}
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".nodemaster.conf")
|
||||
}
|
||||
|
||||
func persist() error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
// 0600: the config now embeds the restic repository password, so it must
|
||||
// not be group/world-readable. Chmod covers pre-existing files that were
|
||||
// created before this field existed (WriteFile's mode only applies on
|
||||
// create, not to files that already exist with looser permissions).
|
||||
if err := os.WriteFile(cfgPath, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(cfgPath, 0600)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigPath returns the resolved config file path.
|
||||
func ConfigPath() string {
|
||||
return cfgPath
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type BackupMethod string
|
||||
|
||||
const (
|
||||
MethodRsync BackupMethod = "rsync"
|
||||
MethodRestic BackupMethod = "restic"
|
||||
MethodExternal BackupMethod = "external"
|
||||
)
|
||||
|
||||
type BackupTarget struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Method BackupMethod `json:"method"`
|
||||
Remote string `json:"remote"` // destination: rsync target path (e.g. "user@host:/path") or restic repository string (e.g. "/mnt/backup/repo", "s3:s3.amazonaws.com/bucket/path", "sftp:user@host:/path")
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
Schedule string `json:"schedule,omitempty"` // cron expression (5-field, standard). Empty = manual-only.
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
}
|
||||
|
||||
type BackupExecution struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Success bool `json:"success"`
|
||||
Duration int64 `json:"duration_seconds"`
|
||||
Message string `json:"message"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
SourcePath string `json:"source_path,omitempty"`
|
||||
Targets []BackupTarget `json:"targets"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"` // aggregate: earliest NextRun across Targets
|
||||
LastRun *time.Time `json:"last_run,omitempty"` // aggregate: latest LastRun across Targets
|
||||
History []BackupExecution `json:"history"`
|
||||
}
|
||||
|
||||
type UpdateRecord struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Success bool `json:"success"`
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type NodeInfo struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UpdateHistory []UpdateRecord `json:"update_history"`
|
||||
Backup BackupConfig `json:"backup"`
|
||||
ResticPassword string `json:"restic_password,omitempty"` // repository password used to encrypt/decrypt all restic backup targets on this node
|
||||
}
|
||||
|
||||
type RemoteNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
LastSeen *time.Time `json:"last_seen,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ComposeFile string `json:"compose_file"`
|
||||
ComposeType string `json:"compose_type,omitempty"`
|
||||
BackupFolder string `json:"backup_folder,omitempty"`
|
||||
Status string `json:"status,omitempty"` // "up", "down", "unknown"
|
||||
StopOnBackup bool `json:"stop_on_backup,omitempty"`
|
||||
ExternalBackupStopMinutes int `json:"external_backup_stop_minutes,omitempty"`
|
||||
ExternalBackupStartMinutes int `json:"external_backup_start_minutes,omitempty"`
|
||||
Backup *BackupConfig `json:"backup,omitempty"`
|
||||
}
|
||||
|
||||
type ScanResult struct {
|
||||
ScannedPaths []string `json:"scanned_paths"`
|
||||
Found int `json:"found"`
|
||||
Created []ServiceScanInfo `json:"created"`
|
||||
Updated []ServiceScanInfo `json:"updated"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceScanInfo struct {
|
||||
Name string `json:"name"`
|
||||
ComposeFile string `json:"compose_file"`
|
||||
BackupFolder string `json:"backup_folder,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Node NodeInfo `json:"node"`
|
||||
Nodes []RemoteNode `json:"nodes"`
|
||||
Services []Service `json:"services"`
|
||||
}
|
||||
Reference in New Issue
Block a user