package lxc import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "time" "clicd/internal/config" ) var snapshotMu sync.Mutex func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { snapshotMu.Lock() defer snapshotMu.Unlock() c := config.FindContainer(id) if c == nil { return config.Snapshot{}, fmt.Errorf("container not found: %d", id) } if scheduled && rotateLimit > 0 { for { existing := config.ContainerSnapshots(id) if len(existing) < rotateLimit { break } sortSnapshotsOldestFirst(existing) if err := m.deleteSnapshotLocked(existing[0]); err != nil { return config.Snapshot{}, err } } } lxcName := c.LxcName() containerDir := filepath.Join(m.LxcPath, lxcName) if _, err := os.Stat(containerDir); err != nil { return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err) } now := time.Now() snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000")) // Use container ID instead of lxcName to avoid collision when containers are recreated snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID) if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil { return config.Snapshot{}, err } if err := os.MkdirAll(snapshotDir, 0700); err != nil { return config.Snapshot{}, err } wasRunning, err := m.prepareContainerForColdCopy(id, lxcName, containerDir) if err != nil { os.RemoveAll(snapshotDir) return config.Snapshot{}, err } if wasRunning { defer func() { if err := m.StartContainer(id); err != nil { fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", lxcName, err) } }() } if err := copyTree(containerDir, snapshotDir); err != nil { os.RemoveAll(snapshotDir) return config.Snapshot{}, err } snapshot := config.Snapshot{ ID: snapshotID, ContainerID: c.ID, ContainerName: c.Name, LXCName: lxcName, CreatedAt: now.Format("2006-01-02 15:04:05"), CreatedBy: createdBy, Scheduled: scheduled, Path: snapshotDir, SizeBytes: dirSizeBytes(snapshotDir), } config.AddSnapshot(snapshot) return snapshot, nil } func (m *Manager) DeleteSnapshot(id string) error { snapshotMu.Lock() defer snapshotMu.Unlock() snapshot := config.FindSnapshot(id) if snapshot == nil { return fmt.Errorf("snapshot not found: %s", id) } return m.deleteSnapshotLocked(*snapshot) } func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error { if snapshot.Path != "" { if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { return err } if err := os.RemoveAll(snapshot.Path); err != nil { return fmt.Errorf("failed to delete snapshot files: %v", err) } } config.RemoveSnapshot(snapshot.ID) return nil } func (m *Manager) RestoreSnapshot(id string) error { snapshotMu.Lock() defer snapshotMu.Unlock() snapshot := config.FindSnapshot(id) if snapshot == nil { return fmt.Errorf("snapshot not found: %s", id) } if snapshot.Path == "" { return fmt.Errorf("snapshot path is empty") } if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { return err } if _, err := os.Stat(snapshot.Path); err != nil { return fmt.Errorf("snapshot files not found: %v", err) } c := config.FindContainer(snapshot.ContainerID) if c == nil { return fmt.Errorf("container not found: %d", snapshot.ContainerID) } lxcName := c.LxcName() containerDir := filepath.Join(m.LxcPath, lxcName) if err := safePathUnder(containerDir, m.LxcPath); err != nil { return err } wasRunning, err := m.prepareContainerForColdCopy(c.ID, lxcName, containerDir) if err != nil { return err } backupDir := filepath.Join(m.LxcPath, fmt.Sprintf(".%s-restore-backup-%d", lxcName, time.Now().UnixNano())) if err := safePathUnder(backupDir, m.LxcPath); err != nil { return err } if err := os.Rename(containerDir, backupDir); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to move current container aside: %v", err) } if err := copyTree(snapshot.Path, containerDir); err != nil { os.RemoveAll(containerDir) _ = os.Rename(backupDir, containerDir) return fmt.Errorf("failed to restore snapshot: %v", err) } _ = os.RemoveAll(backupDir) config.UpdateContainerStatus(c.ID, "stopped") if wasRunning { return m.StartContainer(c.ID) } return nil } func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) { c := config.FindContainer(id) if c == nil { return nil, fmt.Errorf("container not found: %d", id) } if intervalHours < 24 { return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours") } if _, err := parseScheduleClock(scheduleTime); err != nil { return nil, err } c.SnapshotScheduleEnabled = enabled c.SnapshotScheduleIntervalHours = intervalHours c.SnapshotScheduleTime = scheduleTime c.SnapshotScheduleCreatedBy = createdBy if enabled { c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339) } else { c.SnapshotScheduleNextRun = "" } if err := config.SaveConfig(); err != nil { return nil, err } return c, nil } func (m *Manager) StartSnapshotScheduler() { go func() { m.runDueSnapshotSchedules() ticker := time.NewTicker(time.Minute) defer ticker.Stop() for range ticker.C { m.runDueSnapshotSchedules() } }() } func (m *Manager) runDueSnapshotSchedules() { now := time.Now() containers := append([]config.Container(nil), config.AppConfig.Containers...) for _, c := range containers { if c.IsKVM() || !c.SnapshotScheduleEnabled { continue } nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun) if err != nil || c.SnapshotScheduleNextRun == "" { nextRun = now } if now.Before(nextRun) { continue } createdBy := c.SnapshotScheduleCreatedBy if createdBy == "" { createdBy = "admin" } rotateLimit := 0 if strings.HasPrefix(createdBy, "user:") { rotateLimit = config.ContainerSnapshotLimit(&c) } if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil { fmt.Printf("Warning: scheduled snapshot failed for %s: %v\n", c.Name, err) continue } if current := config.FindContainer(c.ID); current != nil { interval := current.SnapshotScheduleIntervalHours if interval < 24 { interval = 24 } next := nextRun.Add(time.Duration(interval) * time.Hour) for !next.After(now) { next = next.Add(time.Duration(interval) * time.Hour) } current.SnapshotScheduleLastRun = now.Format(time.RFC3339) current.SnapshotScheduleNextRun = next.Format(time.RFC3339) config.SaveConfig() } } } func parseScheduleClock(value string) (time.Duration, error) { parts := strings.Split(value, ":") if len(parts) != 2 { return 0, fmt.Errorf("snapshot schedule time must be HH:MM") } hour, err := strconv.Atoi(parts[0]) if err != nil || hour < 0 || hour > 23 { return 0, fmt.Errorf("snapshot schedule hour must be 00-23") } minute, err := strconv.Atoi(parts[1]) if err != nil || minute < 0 || minute > 59 { return 0, fmt.Errorf("snapshot schedule minute must be 00-59") } return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil } func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time { clock, err := parseScheduleClock(scheduleTime) if err != nil { clock = 3 * time.Hour } midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location()) next := midnight.Add(clock) interval := time.Duration(intervalHours) * time.Hour for !next.After(from) { next = next.Add(interval) } return next } func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerDir string) (bool, error) { status, _ := m.GetContainerStatus(lxcName) wasRunning := status == "running" if wasRunning { if err := m.StopContainer(id); err != nil { return false, err } time.Sleep(time.Second) } else if c := config.FindContainer(id); c != nil { m.CleanPortMappings(id) m.cleanupBandwidthLimit(c.LxcName()) } rootfs := filepath.Join(containerDir, "rootfs") exec.Command("umount", "-R", "-l", rootfs).Run() m.detachContainerMounts(containerDir) m.detachContainerLoopDevices(containerDir) return wasRunning, nil } func snapshotBaseDir() string { return filepath.Join(config.AppConfig.DataDir, "snapshots") } func copyTree(src string, dst string) error { if err := os.MkdirAll(dst, 0700); err != nil { return err } output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() if err != nil { output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() if err != nil { return fmt.Errorf("cp failed: %v, output: %s", err, string(output)) } } return nil } func dirSizeBytes(path string) int64 { out, err := exec.Command("du", "-s", "-B1", path).Output() if err != nil { return 0 } parts := strings.Fields(string(out)) if len(parts) == 0 { return 0 } var size int64 fmt.Sscanf(parts[0], "%d", &size) return size } func safePathUnder(path string, base string) error { absPath, err := filepath.Abs(path) if err != nil { return err } absBase, err := filepath.Abs(base) if err != nil { return err } if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) { return nil } return fmt.Errorf("refusing unsafe path: %s", absPath) } func sortSnapshotsOldestFirst(snapshots []config.Snapshot) { sort.SliceStable(snapshots, func(i, j int) bool { ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt) tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt) return ti.Before(tj) }) }