添加了快照功能支持,支持定时快照和回滚快照

This commit is contained in:
MengMengCode
2026-06-06 09:24:19 +08:00
parent bb8a646de7
commit a9784539ea
17 changed files with 1352 additions and 40 deletions
+5
View File
@@ -69,6 +69,8 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost:
assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost:
addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
@@ -121,6 +123,9 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
+3
View File
@@ -34,6 +34,9 @@ func updateOversell(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if cfg.SubUserSnapshotLimit <= 0 {
cfg.SubUserSnapshotLimit = 3
}
// Apply KSM
if cfg.KSMEnabled {
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"clicd/internal/config"
)
func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
}
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch {
case action == "snapshots" && r.Method == http.MethodGet:
listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost:
createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost:
updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut:
updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID)
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot action not found"})
}
}
func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int) {
c := config.FindContainer(containerID)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
snapshots := config.ContainerSnapshots(containerID)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"snapshots": snapshots,
"quota": config.ContainerSnapshotLimit(c),
"schedule": map[string]interface{}{
"enabled": c.SnapshotScheduleEnabled,
"interval_hours": c.SnapshotScheduleIntervalHours,
"time": c.SnapshotScheduleTime,
"last_run": c.SnapshotScheduleLastRun,
"next_run": c.SnapshotScheduleNextRun,
"created_by": c.SnapshotScheduleCreatedBy,
},
}})
}
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
user := requestUser(r)
if isSubUserRequest(r) {
c := config.FindContainer(containerID)
limit := config.ContainerSnapshotLimit(c)
if len(config.ContainerSnapshots(containerID)) >= limit {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Snapshot quota reached. Delete an old snapshot first."})
return
}
}
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
config.AddAuditLog("snapshot.create", snapshot.ContainerName, snapshot.ID, user)
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: snapshot})
}
func updateSnapshotQuota(w http.ResponseWriter, r *http.Request, containerID int) {
if isSubUserRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot change snapshot quota"})
return
}
var req struct {
SnapshotLimit int `json:"snapshot_limit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.SnapshotLimit <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot quota must be at least 1"})
return
}
c := config.FindContainer(containerID)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
c.SnapshotLimit = req.SnapshotLimit
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
return
}
user := requestUser(r)
config.AddAuditLog("snapshot.quota", c.Name, "limit="+strconv.Itoa(req.SnapshotLimit), user)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"container": c,
"quota": c.SnapshotLimit,
}})
}
func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID int) {
var req struct {
Enabled bool `json:"enabled"`
IntervalHours int `json:"interval_hours"`
Time string `json:"time"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.IntervalHours <= 0 {
req.IntervalHours = 24
}
if req.IntervalHours < 24 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot schedule interval cannot be less than 24 hours"})
return
}
if req.Time == "" {
req.Time = "03:00"
}
user := requestUser(r)
c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if req.Enabled {
config.AddAuditLog("snapshot.schedule", c.Name, "enabled", user)
} else {
config.AddAuditLog("snapshot.schedule", c.Name, "disabled", user)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"container": c,
}})
}
func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil || snapshot.ContainerID != containerID {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
user := requestUser(r)
if err := lxcManager.DeleteSnapshot(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
config.AddAuditLog("snapshot.delete", snapshot.ContainerName, snapshot.ID, user)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot deleted"})
}
func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil || snapshot.ContainerID != containerID {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
user := requestUser(r)
if err := lxcManager.RestoreSnapshot(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
config.AddAuditLog("snapshot.restore", snapshot.ContainerName, snapshot.ID, user)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot restored"})
}
func requestUser(r *http.Request) string {
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func sortSnapshotsNewestFirst(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 tj.Before(ti)
})
}
+6
View File
@@ -377,6 +377,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
switch {
case action == "usage" || action == "traffic" || action == "random-port":
return method == http.MethodGet
case action == "snapshots":
return method == http.MethodGet || method == http.MethodPost
case action == "snapshots/schedule":
return method == http.MethodPost
case strings.HasPrefix(action, "snapshots/"):
return method == http.MethodDelete || method == http.MethodPost
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"):
+6
View File
@@ -504,6 +504,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2
}
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
}
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
+166 -39
View File
@@ -56,44 +56,52 @@ type AuditLog struct {
// OversellConfig controls host-level overselling behavior
type OversellConfig struct {
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
SubUserSnapshotLimit int `json:"sub_user_snapshot_limit"` // legacy default for migrating old containers
}
// Container represents an LXC container configuration
type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
LXCName string `json:"lxc_name,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
LXCName string `json:"lxc_name,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
}
// LxcName returns the internal LXC container name (ct-{id})
@@ -138,6 +146,18 @@ type SubUser struct {
CreatedAt string `json:"created_at"`
}
type Snapshot struct {
ID string `json:"id"`
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
Scheduled bool `json:"scheduled"`
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
@@ -157,11 +177,14 @@ type ClicdConfig struct {
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
}
var configPath string
var AppConfig *ClicdConfig
const DefaultSnapshotLimit = 3
func getConfigPath() string {
if configPath != "" {
return configPath
@@ -249,12 +272,14 @@ func InitConfig() (*ClicdConfig, error) {
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Oversell: OversellConfig{
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
SubUserSnapshotLimit: 3,
},
Snapshots: []Snapshot{},
}
if err := SaveConfig(); err != nil {
@@ -304,10 +329,22 @@ func InitConfig() (*ClicdConfig, error) {
if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0)
}
if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0)
}
if AppConfig.Oversell.SubUserSnapshotLimit <= 0 {
AppConfig.Oversell.SubUserSnapshotLimit = 3
}
changed := ensureContainerUUIDs()
if ensureContainerPortMappingLimits() {
changed = true
}
if ensureContainerSnapshotLimits() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() {
changed = true
}
if removeLegacyVNCMappings() {
changed = true
}
@@ -320,6 +357,21 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil
}
func ensureContainerSnapshotScheduleDefaults() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleIntervalHours < 24 {
AppConfig.Containers[i].SnapshotScheduleIntervalHours = 24
changed = true
}
if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleTime == "" {
AppConfig.Containers[i].SnapshotScheduleTime = "03:00"
changed = true
}
}
return changed
}
func ensureContainerUUIDs() bool {
changed := false
used := make(map[string]bool)
@@ -355,6 +407,35 @@ func ensureContainerPortMappingLimits() bool {
return changed
}
func ensureContainerSnapshotLimits() bool {
changed := false
legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit
if legacyLimit <= 0 {
legacyLimit = DefaultSnapshotLimit
}
for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotLimit <= 0 {
AppConfig.Containers[i].SnapshotLimit = legacyLimit
changed = true
}
}
return changed
}
func NormalizeSnapshotLimit(limit int) int {
if limit <= 0 {
return DefaultSnapshotLimit
}
return limit
}
func ContainerSnapshotLimit(c *Container) int {
if c == nil {
return DefaultSnapshotLimit
}
return NormalizeSnapshotLimit(c.SnapshotLimit)
}
func removeLegacyVNCMappings() bool {
changed := false
for i := range AppConfig.Containers {
@@ -408,6 +489,7 @@ func RemoveContainer(id int) bool {
for i, c := range AppConfig.Containers {
if c.ID == id {
removeSubUserContainerAccess(c.Name)
removeContainerSnapshotMetadata(id)
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig()
return true
@@ -416,6 +498,51 @@ func RemoveContainer(id int) bool {
return false
}
func AddSnapshot(snapshot Snapshot) {
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
SaveConfig()
}
func FindSnapshot(id string) *Snapshot {
for i := range AppConfig.Snapshots {
if AppConfig.Snapshots[i].ID == id {
return &AppConfig.Snapshots[i]
}
}
return nil
}
func RemoveSnapshot(id string) bool {
for i := range AppConfig.Snapshots {
if AppConfig.Snapshots[i].ID == id {
AppConfig.Snapshots = append(AppConfig.Snapshots[:i], AppConfig.Snapshots[i+1:]...)
SaveConfig()
return true
}
}
return false
}
func ContainerSnapshots(containerID int) []Snapshot {
result := make([]Snapshot, 0)
for _, snapshot := range AppConfig.Snapshots {
if snapshot.ContainerID == containerID {
result = append(result, snapshot)
}
}
return result
}
func removeContainerSnapshotMetadata(containerID int) {
filtered := make([]Snapshot, 0, len(AppConfig.Snapshots))
for _, snapshot := range AppConfig.Snapshots {
if snapshot.ContainerID != containerID {
filtered = append(filtered, snapshot)
}
}
AppConfig.Snapshots = filtered
}
func removeSubUserContainerAccess(containerName string) {
if containerName == "" || len(AppConfig.SubUsers) == 0 {
return
+13
View File
@@ -216,6 +216,7 @@ type ContainerConfig struct {
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
}
@@ -226,6 +227,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
}
if cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if !config.IsValidContainerName(cfg.Name) {
return fmt.Errorf("invalid container name: %s", cfg.Name)
@@ -351,6 +358,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
SSHPassword: sshPassword,
PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}
@@ -1458,6 +1466,10 @@ func (m *Manager) DestroyContainer(id int) error {
}
return fmt.Errorf("container still exists after cleanup with status %s", status)
}
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(snapshotDir)
}
if !config.RemoveContainer(id) {
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
@@ -1986,6 +1998,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
Status: status,
CreatedAt: time.Now().Format(time.RFC3339),
PortMappingLimit: 2,
SnapshotLimit: config.DefaultSnapshotLimit,
}
if status == "running" {
+349
View File
@@ -0,0 +1,349 @@
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"))
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, 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.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)
})
}
+1
View File
@@ -50,6 +50,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))