diff --git a/engine/cmd/cli/commands/clone/actions.go b/engine/cmd/cli/commands/clone/actions.go index 3eca7e3f..891b15d4 100644 --- a/engine/cmd/cli/commands/clone/actions.go +++ b/engine/cmd/cli/commands/clone/actions.go @@ -184,8 +184,9 @@ func update(cliCtx *cli.Context) error { return err } + protected := cliCtx.Bool("protected") updateRequest := types.CloneUpdateRequest{ - Protected: cliCtx.Bool("protected"), + Protected: &protected, } cloneID := cliCtx.Args().First() diff --git a/engine/cmd/database-lab/main.go b/engine/cmd/database-lab/main.go index 649c4825..a96404a2 100644 --- a/engine/cmd/database-lab/main.go +++ b/engine/cmd/database-lab/main.go @@ -199,6 +199,8 @@ func main() { } }() + go server.RunAutoDeleteCheck(ctx) + if cfg.EmbeddedUI.Enabled { go func() { if err := embeddedUI.Run(ctx); err != nil { diff --git a/engine/internal/cloning/base.go b/engine/internal/cloning/base.go index 952a9436..c993e22a 100644 --- a/engine/internal/cloning/base.go +++ b/engine/internal/cloning/base.go @@ -45,28 +45,30 @@ type Config struct { // Base provides cloning service. type Base struct { - config *Config - global *global.Config - cloneMutex sync.RWMutex - clones map[string]*CloneWrapper - snapshotBox SnapshotBox - provision *provision.Provisioner - tm *telemetry.Agent - observingCh chan string - webhookCh chan webhooks.EventTyper + config *Config + global *global.Config + cloneMutex sync.RWMutex + clones map[string]*CloneWrapper + snapshotBox SnapshotBox + entityStorage *EntityStorage + provision *provision.Provisioner + tm *telemetry.Agent + observingCh chan string + webhookCh chan webhooks.EventTyper } // NewBase instances a new Base service. func NewBase(cfg *Config, global *global.Config, provision *provision.Provisioner, tm *telemetry.Agent, observingCh chan string, whCh chan webhooks.EventTyper) *Base { return &Base{ - config: cfg, - global: global, - clones: make(map[string]*CloneWrapper), - provision: provision, - tm: tm, - observingCh: observingCh, - webhookCh: whCh, + config: cfg, + global: global, + clones: make(map[string]*CloneWrapper), + entityStorage: NewEntityStorage(), + provision: provision, + tm: tm, + observingCh: observingCh, + webhookCh: whCh, snapshotBox: SnapshotBox{ items: make(map[string]*models.Snapshot), }, @@ -93,6 +95,14 @@ func (c *Base) Run(ctx context.Context) error { log.Err("failed to load stored sessions:", err) } + if err := c.entityStorage.RestoreBranchesState(); err != nil { + log.Err("failed to load stored branch metadata:", err) + } + + if err := c.entityStorage.RestoreSnapshotsState(); err != nil { + log.Err("failed to load stored snapshot metadata:", err) + } + c.restartCloneContainers(ctx) c.filterRunningClones(ctx) @@ -175,12 +185,20 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon cloneRequest.Branch = snapshot.Branch } + var deleteAt *models.LocalTime + + if cloneRequest.DeleteAt != "" { + deleteAt, _ = models.ParseLocalTime(cloneRequest.DeleteAt) + } + clone := &models.Clone{ - ID: cloneRequest.ID, - Snapshot: snapshot, - Branch: cloneRequest.Branch, - Protected: cloneRequest.Protected, - CreatedAt: models.NewLocalTime(createdAt), + ID: cloneRequest.ID, + Snapshot: snapshot, + Branch: cloneRequest.Branch, + Protected: cloneRequest.Protected, + DeleteAt: deleteAt, + AutoDeleteMode: models.AutoDeleteMode(cloneRequest.AutoDeleteMode), + CreatedAt: models.NewLocalTime(createdAt), Status: models.Status{ Code: models.StatusCreating, Message: models.CloneMessageCreating, @@ -450,9 +468,23 @@ func (c *Base) UpdateClone(id string, patch types.CloneUpdateRequest) (*models.C var clone *models.Clone - // Set fields. c.cloneMutex.Lock() - w.Clone.Protected = patch.Protected + + if patch.Protected != nil { + w.Clone.Protected = *patch.Protected + } + + if patch.DeleteAt != nil { + deleteAt, err := models.ParseLocalTime(*patch.DeleteAt) + if err == nil { + w.Clone.DeleteAt = deleteAt + } + } + + if patch.AutoDeleteMode != nil { + w.Clone.AutoDeleteMode = models.AutoDeleteMode(*patch.AutoDeleteMode) + } + clone = w.Clone c.cloneMutex.Unlock() @@ -461,6 +493,50 @@ func (c *Base) UpdateClone(id string, patch types.CloneUpdateRequest) (*models.C return clone, nil } +// GetEntityStorage returns the entity storage instance. +func (c *Base) GetEntityStorage() *EntityStorage { + return c.entityStorage +} + +// IsBranchProtected checks if a branch is protected. +func (c *Base) IsBranchProtected(name string) bool { + return c.entityStorage.IsBranchProtected(name) +} + +// IsSnapshotProtected checks if a snapshot is protected. +func (c *Base) IsSnapshotProtected(id string) bool { + return c.entityStorage.IsSnapshotProtected(id) +} + +// GetBranchMeta returns metadata for a branch. +func (c *Base) GetBranchMeta(name string) *BranchMeta { + return c.entityStorage.GetBranchMeta(name) +} + +// GetSnapshotMeta returns metadata for a snapshot. +func (c *Base) GetSnapshotMeta(id string) *SnapshotMeta { + return c.entityStorage.GetSnapshotMeta(id) +} + +// UpdateBranchMeta updates branch metadata. +func (c *Base) UpdateBranchMeta( + name string, protected *bool, deleteAt *models.LocalTime, autoDeleteMode *models.AutoDeleteMode, +) *BranchMeta { + return c.entityStorage.UpdateBranchMeta(name, protected, deleteAt, autoDeleteMode) +} + +// UpdateSnapshotMeta updates snapshot metadata. +func (c *Base) UpdateSnapshotMeta( + id string, protected *bool, deleteAt *models.LocalTime, autoDeleteMode *models.AutoDeleteMode, +) *SnapshotMeta { + return c.entityStorage.UpdateSnapshotMeta(id, protected, deleteAt, autoDeleteMode) +} + +// DeleteBranchMeta removes branch metadata. +func (c *Base) DeleteBranchMeta(name string) { + c.entityStorage.DeleteBranchMeta(name) +} + // UpdateCloneStatus updates the clone status. func (c *Base) UpdateCloneStatus(cloneID string, status models.Status) error { c.cloneMutex.Lock() @@ -723,16 +799,16 @@ func (c *Base) getExpectedCloningTime() float64 { } func (c *Base) runIdleCheck(ctx context.Context) { - if c.config.MaxIdleMinutes == 0 { - return - } - idleTimer := time.NewTimer(idleCheckDuration) for { select { case <-idleTimer.C: - c.destroyIdleClones(ctx) + if c.config.MaxIdleMinutes > 0 { + c.destroyIdleClones(ctx) + } + + c.destroyScheduledClones(ctx) idleTimer.Reset(idleCheckDuration) c.SaveClonesState() @@ -767,6 +843,61 @@ func (c *Base) destroyIdleClones(ctx context.Context) { } } +func (c *Base) destroyScheduledClones(ctx context.Context) { + now := time.Now() + + for _, cloneWrapper := range c.clones { + select { + case <-ctx.Done(): + return + default: + clone := cloneWrapper.Clone + if clone.DeleteAt == nil || clone.AutoDeleteMode == models.AutoDeleteOff { + continue + } + + if clone.DeleteAt.Time.After(now) { + continue + } + + if clone.Protected { + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of protected clone %q", clone.ID)) + continue + } + + hasDeps := c.hasDependentSnapshots(cloneWrapper) + + switch clone.AutoDeleteMode { + case models.AutoDeleteSoft: + if hasDeps { + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of clone %q: has dependent snapshots (soft mode)", clone.ID)) + continue + } + case models.AutoDeleteForce: + if hasDeps { + log.Msg(fmt.Sprintf("Force deleting clone %q with dependent snapshots", clone.ID)) + } + } + + log.Msg(fmt.Sprintf("Scheduled clone %q is going to be removed (deleteAt: %s)", clone.ID, clone.DeleteAt.Time.Format(time.RFC3339))) + + if err := c.DestroyClone(clone.ID); err != nil { + log.Errf("failed to destroy scheduled clone %q: %v", clone.ID, err) + } + } + } +} + +// GetExpiredBranches returns branch metadata for expired branches. +func (c *Base) GetExpiredBranches() []*BranchMeta { + return c.entityStorage.GetExpiredBranches() +} + +// GetExpiredSnapshots returns snapshot metadata for expired snapshots. +func (c *Base) GetExpiredSnapshots() []*SnapshotMeta { + return c.entityStorage.GetExpiredSnapshots() +} + // isIdleClone checks if clone is idle. func (c *Base) isIdleClone(wrapper *CloneWrapper) (bool, error) { currentTime := time.Now() diff --git a/engine/internal/cloning/entity_storage.go b/engine/internal/cloning/entity_storage.go new file mode 100644 index 00000000..169b8641 --- /dev/null +++ b/engine/internal/cloning/entity_storage.go @@ -0,0 +1,346 @@ +/* +2024 © Postgres.ai +*/ + +package cloning + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util" +) + +const ( + branchesFilename = "branches.json" + snapshotsFilename = "snapshots_meta.json" +) + +// BranchMeta stores branch metadata that needs to be persisted. +type BranchMeta struct { + Name string `json:"name"` + Protected bool `json:"protected"` + DeleteAt *models.LocalTime `json:"deleteAt,omitempty"` + AutoDeleteMode models.AutoDeleteMode `json:"autoDeleteMode"` +} + +// SnapshotMeta stores snapshot metadata that needs to be persisted. +type SnapshotMeta struct { + ID string `json:"id"` + Protected bool `json:"protected"` + DeleteAt *models.LocalTime `json:"deleteAt,omitempty"` + AutoDeleteMode models.AutoDeleteMode `json:"autoDeleteMode"` +} + +// EntityStorage manages branch and snapshot metadata persistence. +type EntityStorage struct { + branchMutex sync.RWMutex + snapshotMutex sync.RWMutex + branches map[string]*BranchMeta + snapshots map[string]*SnapshotMeta +} + +// NewEntityStorage creates a new entity storage instance. +func NewEntityStorage() *EntityStorage { + return &EntityStorage{ + branches: make(map[string]*BranchMeta), + snapshots: make(map[string]*SnapshotMeta), + } +} + +// RestoreBranchesState restores branch metadata from disk. +func (es *EntityStorage) RestoreBranchesState() error { + branchesPath, err := util.GetMetaPath(branchesFilename) + if err != nil { + return fmt.Errorf("failed to get path of branches file: %w", err) + } + + es.branchMutex.Lock() + defer es.branchMutex.Unlock() + + es.branches = make(map[string]*BranchMeta) + + data, err := os.ReadFile(branchesPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to read branches data: %w", err) + } + + return json.Unmarshal(data, &es.branches) +} + +// SaveBranchesState writes branch metadata to disk. +func (es *EntityStorage) SaveBranchesState() { + branchesPath, err := util.GetMetaPath(branchesFilename) + if err != nil { + log.Err("failed to get path of branches file", err) + return + } + + es.branchMutex.RLock() + defer es.branchMutex.RUnlock() + + data, err := json.Marshal(es.branches) + if err != nil { + log.Err("failed to encode branches data", err) + return + } + + if err := os.WriteFile(branchesPath, data, 0600); err != nil { + log.Err("failed to save branches state", err) + } +} + +// RestoreSnapshotsState restores snapshot metadata from disk. +func (es *EntityStorage) RestoreSnapshotsState() error { + snapshotsPath, err := util.GetMetaPath(snapshotsFilename) + if err != nil { + return fmt.Errorf("failed to get path of snapshots file: %w", err) + } + + es.snapshotMutex.Lock() + defer es.snapshotMutex.Unlock() + + es.snapshots = make(map[string]*SnapshotMeta) + + data, err := os.ReadFile(snapshotsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to read snapshots metadata: %w", err) + } + + return json.Unmarshal(data, &es.snapshots) +} + +// SaveSnapshotsState writes snapshot metadata to disk. +func (es *EntityStorage) SaveSnapshotsState() { + snapshotsPath, err := util.GetMetaPath(snapshotsFilename) + if err != nil { + log.Err("failed to get path of snapshots file", err) + return + } + + es.snapshotMutex.RLock() + defer es.snapshotMutex.RUnlock() + + data, err := json.Marshal(es.snapshots) + if err != nil { + log.Err("failed to encode snapshots metadata", err) + return + } + + if err := os.WriteFile(snapshotsPath, data, 0600); err != nil { + log.Err("failed to save snapshots metadata state", err) + } +} + +// GetBranchMeta returns metadata for a branch. +func (es *EntityStorage) GetBranchMeta(name string) *BranchMeta { + es.branchMutex.RLock() + defer es.branchMutex.RUnlock() + + return es.branches[name] +} + +// SetBranchMeta sets metadata for a branch. +func (es *EntityStorage) SetBranchMeta(meta *BranchMeta) { + es.branchMutex.Lock() + es.branches[meta.Name] = meta + es.branchMutex.Unlock() + + es.SaveBranchesState() +} + +// UpdateBranchMeta updates existing branch metadata or creates new one. +func (es *EntityStorage) UpdateBranchMeta( + name string, protected *bool, deleteAt *models.LocalTime, autoDeleteMode *models.AutoDeleteMode, +) *BranchMeta { + es.branchMutex.Lock() + defer es.branchMutex.Unlock() + + meta, exists := es.branches[name] + if !exists { + meta = &BranchMeta{Name: name} + es.branches[name] = meta + } + + if protected != nil { + meta.Protected = *protected + } + + if deleteAt != nil { + meta.DeleteAt = deleteAt + } + + if autoDeleteMode != nil { + meta.AutoDeleteMode = *autoDeleteMode + } + + go es.SaveBranchesState() + + return meta +} + +// DeleteBranchMeta removes metadata for a branch. +func (es *EntityStorage) DeleteBranchMeta(name string) { + es.branchMutex.Lock() + delete(es.branches, name) + es.branchMutex.Unlock() + + es.SaveBranchesState() +} + +// IsBranchProtected checks if a branch is protected. +func (es *EntityStorage) IsBranchProtected(name string) bool { + es.branchMutex.RLock() + defer es.branchMutex.RUnlock() + + if meta, ok := es.branches[name]; ok { + return meta.Protected + } + + return false +} + +// GetSnapshotMeta returns metadata for a snapshot. +func (es *EntityStorage) GetSnapshotMeta(id string) *SnapshotMeta { + es.snapshotMutex.RLock() + defer es.snapshotMutex.RUnlock() + + return es.snapshots[id] +} + +// SetSnapshotMeta sets metadata for a snapshot. +func (es *EntityStorage) SetSnapshotMeta(meta *SnapshotMeta) { + es.snapshotMutex.Lock() + es.snapshots[meta.ID] = meta + es.snapshotMutex.Unlock() + + es.SaveSnapshotsState() +} + +// UpdateSnapshotMeta updates existing snapshot metadata or creates new one. +func (es *EntityStorage) UpdateSnapshotMeta( + id string, protected *bool, deleteAt *models.LocalTime, autoDeleteMode *models.AutoDeleteMode, +) *SnapshotMeta { + es.snapshotMutex.Lock() + defer es.snapshotMutex.Unlock() + + meta, exists := es.snapshots[id] + if !exists { + meta = &SnapshotMeta{ID: id} + es.snapshots[id] = meta + } + + if protected != nil { + meta.Protected = *protected + } + + if deleteAt != nil { + meta.DeleteAt = deleteAt + } + + if autoDeleteMode != nil { + meta.AutoDeleteMode = *autoDeleteMode + } + + go es.SaveSnapshotsState() + + return meta +} + +// DeleteSnapshotMeta removes metadata for a snapshot. +func (es *EntityStorage) DeleteSnapshotMeta(id string) { + es.snapshotMutex.Lock() + delete(es.snapshots, id) + es.snapshotMutex.Unlock() + + es.SaveSnapshotsState() +} + +// IsSnapshotProtected checks if a snapshot is protected. +func (es *EntityStorage) IsSnapshotProtected(id string) bool { + es.snapshotMutex.RLock() + defer es.snapshotMutex.RUnlock() + + if meta, ok := es.snapshots[id]; ok { + return meta.Protected + } + + return false +} + +// GetExpiredBranches returns branches that have passed their deleteAt time. +func (es *EntityStorage) GetExpiredBranches() []*BranchMeta { + es.branchMutex.RLock() + defer es.branchMutex.RUnlock() + + now := time.Now() + expired := make([]*BranchMeta, 0) + + for _, meta := range es.branches { + if meta.DeleteAt != nil && meta.AutoDeleteMode != models.AutoDeleteOff { + if meta.DeleteAt.Time.Before(now) { + expired = append(expired, meta) + } + } + } + + return expired +} + +// GetExpiredSnapshots returns snapshots that have passed their deleteAt time. +func (es *EntityStorage) GetExpiredSnapshots() []*SnapshotMeta { + es.snapshotMutex.RLock() + defer es.snapshotMutex.RUnlock() + + now := time.Now() + expired := make([]*SnapshotMeta, 0) + + for _, meta := range es.snapshots { + if meta.DeleteAt != nil && meta.AutoDeleteMode != models.AutoDeleteOff { + if meta.DeleteAt.Time.Before(now) { + expired = append(expired, meta) + } + } + } + + return expired +} + +// ListBranchMetas returns all branch metadata. +func (es *EntityStorage) ListBranchMetas() map[string]*BranchMeta { + es.branchMutex.RLock() + defer es.branchMutex.RUnlock() + + result := make(map[string]*BranchMeta, len(es.branches)) + for k, v := range es.branches { + result[k] = v + } + + return result +} + +// ListSnapshotMetas returns all snapshot metadata. +func (es *EntityStorage) ListSnapshotMetas() map[string]*SnapshotMeta { + es.snapshotMutex.RLock() + defer es.snapshotMutex.RUnlock() + + result := make(map[string]*SnapshotMeta, len(es.snapshots)) + for k, v := range es.snapshots { + result[k] = v + } + + return result +} diff --git a/engine/internal/srv/auto_delete.go b/engine/internal/srv/auto_delete.go new file mode 100644 index 00000000..5904ace2 --- /dev/null +++ b/engine/internal/srv/auto_delete.go @@ -0,0 +1,318 @@ +/* +2024 © Postgres.ai +*/ + +package srv + +import ( + "context" + "fmt" + "strings" + "time" + + "gitlab.com/postgres-ai/database-lab/v3/internal/cloning" + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/pool" + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util/branching" +) + +const autoDeleteCheckInterval = 5 * time.Minute + +// RunAutoDeleteCheck starts a background job that periodically checks for expired branches and snapshots. +func (s *Server) RunAutoDeleteCheck(ctx context.Context) { + timer := time.NewTimer(autoDeleteCheckInterval) + + for { + select { + case <-timer.C: + s.processExpiredBranches(ctx) + s.processExpiredSnapshots(ctx) + timer.Reset(autoDeleteCheckInterval) + + case <-ctx.Done(): + timer.Stop() + return + } + } +} + +func (s *Server) processExpiredBranches(ctx context.Context) { + expiredBranches := s.Cloning.GetExpiredBranches() + if len(expiredBranches) == 0 { + return + } + + for _, meta := range expiredBranches { + select { + case <-ctx.Done(): + return + default: + } + + if meta.Protected { + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of protected branch %q", meta.Name)) + continue + } + + if meta.Name == branching.DefaultBranch { + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of default branch %q", meta.Name)) + continue + } + + fsm, err := s.getFSManagerForBranch(meta.Name) + if err != nil { + log.Errf("failed to get FSManager for branch %q: %v", meta.Name, err) + continue + } + + if fsm == nil { + log.Errf("no pool manager found for branch %q", meta.Name) + continue + } + + canDelete, reason := s.canDeleteBranch(fsm, meta) + if !canDelete { + switch meta.AutoDeleteMode { + case models.AutoDeleteSoft: + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of branch %q: %s (soft mode)", meta.Name, reason)) + continue + case models.AutoDeleteForce: + log.Msg(fmt.Sprintf("Force deleting branch %q: %s", meta.Name, reason)) + + if err := s.forceDeleteBranch(ctx, fsm, meta.Name); err != nil { + log.Errf("failed to force delete branch %q: %v", meta.Name, err) + continue + } + + s.Cloning.DeleteBranchMeta(meta.Name) + + continue + } + } + + log.Msg(fmt.Sprintf("Scheduled branch %q is going to be removed (deleteAt: %s)", meta.Name, meta.DeleteAt.Time.Format(time.RFC3339))) + + if err := s.destroyBranchDataset(fsm, meta.Name); err != nil { + log.Errf("failed to destroy scheduled branch %q: %v", meta.Name, err) + continue + } + + s.Cloning.DeleteBranchMeta(meta.Name) + } +} + +func (s *Server) canDeleteBranch(fsm pool.FSManager, meta *cloning.BranchMeta) (bool, string) { + repo, err := fsm.GetRepo() + if err != nil { + return false, fmt.Sprintf("failed to get repo: %v", err) + } + + snapshotID, ok := repo.Branches[meta.Name] + if !ok { + return false, "branch not found" + } + + toRemove := snapshotsToRemove(repo, snapshotID, meta.Name) + + for _, snapID := range toRemove { + if cloneNum := s.Cloning.GetCloneNumber(snapID); cloneNum > 0 { + return false, fmt.Sprintf("snapshot %q has %d dependent clone(s)", snapID, cloneNum) + } + } + + return true, "" +} + +func (s *Server) forceDeleteBranch(ctx context.Context, fsm pool.FSManager, branchName string) error { + repo, err := fsm.GetRepo() + if err != nil { + return fmt.Errorf("failed to get repo: %w", err) + } + + snapshotID, ok := repo.Branches[branchName] + if !ok { + return fmt.Errorf("branch not found: %s", branchName) + } + + toRemove := snapshotsToRemove(repo, snapshotID, branchName) + + for _, snapID := range toRemove { + clones := s.getClonesForSnapshot(snapID) + for _, cloneID := range clones { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := s.Cloning.DestroyCloneSync(cloneID); err != nil { + log.Errf("failed to destroy clone %q during branch force delete: %v", cloneID, err) + } + } + } + + return s.destroyBranchDataset(fsm, branchName) +} + +func (s *Server) getClonesForSnapshot(snapshotID string) []string { + state := s.Cloning.GetCloningState() + + var cloneIDs []string + + for _, clone := range state.Clones { + if clone.Snapshot != nil && clone.Snapshot.ID == snapshotID { + cloneIDs = append(cloneIDs, clone.ID) + } + } + + return cloneIDs +} + +func (s *Server) processExpiredSnapshots(ctx context.Context) { + expiredSnapshots := s.Cloning.GetExpiredSnapshots() + if len(expiredSnapshots) == 0 { + return + } + + for _, meta := range expiredSnapshots { + select { + case <-ctx.Done(): + return + default: + } + + if meta.Protected { + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of protected snapshot %q", meta.ID)) + continue + } + + poolName, err := s.detectPoolName(meta.ID) + if err != nil { + log.Errf("failed to detect pool for snapshot %q: %v", meta.ID, err) + continue + } + + if poolName == "" { + log.Errf("pool for snapshot %q not found", meta.ID) + continue + } + + fsm, err := s.pm.GetFSManager(poolName) + if err != nil { + log.Errf("failed to get FSManager for pool %q: %v", poolName, err) + continue + } + + canDelete, reason, cloneIDs := s.canDeleteSnapshot(fsm, meta, poolName) + if !canDelete { + switch meta.AutoDeleteMode { + case models.AutoDeleteSoft: + log.Dbg(fmt.Sprintf("Skipping scheduled deletion of snapshot %q: %s (soft mode)", meta.ID, reason)) + continue + case models.AutoDeleteForce: + log.Msg(fmt.Sprintf("Force deleting snapshot %q: %s", meta.ID, reason)) + + if err := s.forceDeleteSnapshot(ctx, fsm, meta.ID, cloneIDs); err != nil { + log.Errf("failed to force delete snapshot %q: %v", meta.ID, err) + continue + } + + s.Cloning.GetEntityStorage().DeleteSnapshotMeta(meta.ID) + + continue + } + } + + log.Msg(fmt.Sprintf("Scheduled snapshot %q is going to be removed (deleteAt: %s)", meta.ID, meta.DeleteAt.Time.Format(time.RFC3339))) + + if err := fsm.DestroySnapshot(meta.ID, thinclones.DestroyOptions{}); err != nil { + log.Errf("failed to destroy scheduled snapshot %q: %v", meta.ID, err) + continue + } + + s.Cloning.GetEntityStorage().DeleteSnapshotMeta(meta.ID) + + fsm.RefreshSnapshotList() + + if err := s.Cloning.ReloadSnapshots(); err != nil { + log.Dbg("Failed to reload snapshots:", err.Error()) + } + + s.webhookCh <- webhooks.BasicEvent{ + EventType: webhooks.SnapshotDeleteEvent, + EntityID: meta.ID, + } + } +} + +func (s *Server) canDeleteSnapshot(fsm pool.FSManager, meta *cloning.SnapshotMeta, poolName string) (bool, string, []string) { + dependentCloneDatasets, err := fsm.HasDependentEntity(meta.ID) + if err != nil { + return false, fmt.Sprintf("failed to check dependencies: %v", err), nil + } + + cloneIDs := make([]string, 0, len(dependentCloneDatasets)) + protectedClones := make([]string, 0) + + for _, cloneDataset := range dependentCloneDatasets { + cloneID, ok := branching.ParseCloneName(cloneDataset, poolName) + if !ok { + continue + } + + clone, err := s.Cloning.GetClone(cloneID) + if err != nil { + continue + } + + cloneIDs = append(cloneIDs, clone.ID) + + if clone.Protected { + protectedClones = append(protectedClones, clone.ID) + } + } + + if len(protectedClones) > 0 { + return false, fmt.Sprintf("has protected clones: %s", strings.Join(protectedClones, ",")), cloneIDs + } + + if len(cloneIDs) > 0 { + return false, fmt.Sprintf("has dependent clones: %s", strings.Join(cloneIDs, ",")), cloneIDs + } + + return true, "", nil +} + +func (s *Server) forceDeleteSnapshot(ctx context.Context, fsm pool.FSManager, snapshotID string, cloneIDs []string) error { + for _, cloneID := range cloneIDs { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := s.Cloning.DestroyCloneSync(cloneID); err != nil { + log.Errf("failed to destroy clone %q during snapshot force delete: %v", cloneID, err) + } + } + + if err := fsm.DestroySnapshot(snapshotID, thinclones.DestroyOptions{}); err != nil { + return fmt.Errorf("failed to destroy snapshot: %w", err) + } + + fsm.RefreshSnapshotList() + + if err := s.Cloning.ReloadSnapshots(); err != nil { + log.Dbg("Failed to reload snapshots:", err.Error()) + } + + s.webhookCh <- webhooks.BasicEvent{ + EventType: webhooks.SnapshotDeleteEvent, + EntityID: snapshotID, + } + + return nil +} diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 1941591b..ad4d63e7 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -65,6 +65,12 @@ func (s *Server) listBranches(w http.ResponseWriter, r *http.Request) { NumSnapshots: numSnapshots, } + if meta := s.Cloning.GetBranchMeta(branchEntity.Name); meta != nil { + branchView.Protected = meta.Protected + branchView.DeleteAt = meta.DeleteAt + branchView.AutoDeleteMode = meta.AutoDeleteMode + } + branchDetails = append(branchDetails, branchView) } @@ -279,7 +285,27 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { fsm.RefreshSnapshotList() - branch := models.Branch{Name: createRequest.BranchName} + var deleteAt *models.LocalTime + + if createRequest.DeleteAt != "" { + deleteAt, err = models.ParseLocalTime(createRequest.DeleteAt) + if err != nil { + log.Warn(fmt.Sprintf("failed to parse deleteAt for branch %s: %v", createRequest.BranchName, err)) + } + } + + autoDeleteMode := models.AutoDeleteMode(createRequest.AutoDeleteMode) + + if createRequest.Protected || deleteAt != nil || autoDeleteMode != models.AutoDeleteOff { + s.Cloning.UpdateBranchMeta(createRequest.BranchName, &createRequest.Protected, deleteAt, &autoDeleteMode) + } + + branch := models.Branch{ + Name: createRequest.BranchName, + Protected: createRequest.Protected, + DeleteAt: deleteAt, + AutoDeleteMode: autoDeleteMode, + } s.webhookCh <- webhooks.BasicEvent{ EventType: webhooks.BranchCreateEvent, @@ -540,6 +566,11 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { branchName := mux.Vars(r)["branchName"] + if s.Cloning.IsBranchProtected(branchName) { + api.SendBadRequestError(w, r, "branch is protected") + return + } + fsm, err := s.getFSManagerForBranch(branchName) if err != nil { api.SendBadRequestError(w, r, err.Error()) @@ -601,6 +632,8 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } + s.Cloning.DeleteBranchMeta(branchName) + if err := api.WriteJSON(w, http.StatusOK, models.Response{ Status: models.ResponseOK, Message: "Deleted branch", @@ -728,3 +761,76 @@ func (s *Server) destroyBranchDataset(fsm pool.FSManager, branchName string) err return nil } + +func (s *Server) updateBranch(w http.ResponseWriter, r *http.Request) { + branchName := mux.Vars(r)["branchName"] + + fsm, err := s.getFSManagerForBranch(branchName) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if fsm == nil { + api.SendBadRequestError(w, r, "no pool manager found") + return + } + + repo, err := fsm.GetRepo() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if _, ok := repo.Branches[branchName]; !ok { + api.SendNotFoundError(w, r) + return + } + + var updateRequest types.BranchUpdateRequest + if err := api.ReadJSON(r, &updateRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + var deleteAt *models.LocalTime + + if updateRequest.DeleteAt != nil { + deleteAt, err = models.ParseLocalTime(*updateRequest.DeleteAt) + if err != nil { + api.SendBadRequestError(w, r, fmt.Sprintf("invalid deleteAt format: %v", err)) + return + } + } + + var autoDeleteMode *models.AutoDeleteMode + + if updateRequest.AutoDeleteMode != nil { + mode := models.AutoDeleteMode(*updateRequest.AutoDeleteMode) + if !mode.IsValid() { + api.SendBadRequestError(w, r, "invalid autoDeleteMode, must be 0, 1, or 2") + return + } + + autoDeleteMode = &mode + } + + meta := s.Cloning.UpdateBranchMeta(branchName, updateRequest.Protected, deleteAt, autoDeleteMode) + + branch := models.Branch{ + Name: branchName, + Protected: meta.Protected, + DeleteAt: meta.DeleteAt, + AutoDeleteMode: meta.AutoDeleteMode, + } + + s.tm.SendEvent(context.Background(), telemetry.BranchUpdatedEvent, telemetry.BranchUpdated{ + Name: branchName, + Protected: meta.Protected, + }) + + if err := api.WriteJSON(w, http.StatusOK, branch); err != nil { + api.SendError(w, r, err) + return + } +} diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 82458171..0ab54dd9 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -214,6 +214,11 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { return } + if s.Cloning.IsSnapshotProtected(snapshotID) { + api.SendBadRequestError(w, r, "snapshot is protected") + return + } + forceParam := r.URL.Query().Get("force") force := false @@ -467,6 +472,91 @@ func isRoot(root, branch string) bool { return containsString(rootBranches, branch) } +func (s *Server) updateSnapshot(w http.ResponseWriter, r *http.Request) { + snapshotID := mux.Vars(r)["id"] + if snapshotID == "" { + api.SendBadRequestError(w, r, "snapshot ID must not be empty") + return + } + + poolName, err := s.detectPoolName(snapshotID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if poolName == "" { + api.SendBadRequestError(w, r, fmt.Sprintf("pool for requested snapshot (%s) not found", snapshotID)) + return + } + + fsm, err := s.pm.GetFSManager(poolName) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if _, err := fsm.GetSnapshotProperties(snapshotID); err != nil { + if runnerError, ok := err.(runners.RunnerError); ok { + api.SendBadRequestError(w, r, runnerError.Stderr) + } else { + api.SendBadRequestError(w, r, err.Error()) + } + + return + } + + var updateRequest types.SnapshotUpdateRequest + if err := api.ReadJSON(r, &updateRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + var deleteAt *models.LocalTime + + if updateRequest.DeleteAt != nil { + deleteAt, err = models.ParseLocalTime(*updateRequest.DeleteAt) + if err != nil { + api.SendBadRequestError(w, r, fmt.Sprintf("invalid deleteAt format: %v", err)) + return + } + } + + var autoDeleteMode *models.AutoDeleteMode + + if updateRequest.AutoDeleteMode != nil { + mode := models.AutoDeleteMode(*updateRequest.AutoDeleteMode) + if !mode.IsValid() { + api.SendBadRequestError(w, r, "invalid autoDeleteMode, must be 0, 1, or 2") + return + } + + autoDeleteMode = &mode + } + + meta := s.Cloning.UpdateSnapshotMeta(snapshotID, updateRequest.Protected, deleteAt, autoDeleteMode) + + snapshot, err := s.Cloning.GetSnapshotByID(snapshotID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + snapshot.Protected = meta.Protected + snapshot.DeleteAt = meta.DeleteAt + snapshot.AutoDeleteMode = meta.AutoDeleteMode + + s.tm.SendEvent(r.Context(), telemetry.SnapshotUpdatedEvent, telemetry.SnapshotUpdated{ + ID: snapshotID, + Protected: meta.Protected, + }) + + if err := api.WriteJSON(w, http.StatusOK, snapshot); err != nil { + api.SendError(w, r, err) + return + } +} + func (s *Server) detectPoolName(snapshotID string) (string, error) { const snapshotParts = 2 @@ -732,7 +822,7 @@ func (s *Server) patchClone(w http.ResponseWriter, r *http.Request) { s.tm.SendEvent(context.Background(), telemetry.CloneUpdatedEvent, telemetry.CloneUpdated{ ID: util.HashID(cloneID), - Protected: patchClone.Protected, + Protected: updatedClone.Protected, }) if err := api.WriteJSON(w, http.StatusOK, updatedClone); err != nil { diff --git a/engine/internal/srv/server.go b/engine/internal/srv/server.go index af11b633..ed2fa274 100644 --- a/engine/internal/srv/server.go +++ b/engine/internal/srv/server.go @@ -199,6 +199,7 @@ func (s *Server) InitHandlers() { r.HandleFunc("/snapshots", authMW.Authorized(s.getSnapshots)).Methods(http.MethodGet) r.HandleFunc("/snapshot/{id:.*}", authMW.Authorized(s.getSnapshot)).Methods(http.MethodGet) r.HandleFunc("/snapshot", authMW.Authorized(s.createSnapshot)).Methods(http.MethodPost) + r.HandleFunc("/snapshot/{id:.*}", authMW.Authorized(s.updateSnapshot)).Methods(http.MethodPatch) r.HandleFunc("/snapshot/{id:.*}", authMW.Authorized(s.deleteSnapshot)).Methods(http.MethodDelete) r.HandleFunc("/snapshot/clone", authMW.Authorized(s.createSnapshotClone)).Methods(http.MethodPost) r.HandleFunc("/clones", authMW.Authorized(s.clones)).Methods(http.MethodGet) @@ -218,6 +219,7 @@ func (s *Server) InitHandlers() { r.HandleFunc("/branch", authMW.Authorized(s.createBranch)).Methods(http.MethodPost) r.HandleFunc("/branch/snapshot", authMW.Authorized(s.snapshot)).Methods(http.MethodPost) r.HandleFunc("/branch/{branchName}/log", authMW.Authorized(s.log)).Methods(http.MethodGet) + r.HandleFunc("/branch/{branchName}", authMW.Authorized(s.updateBranch)).Methods(http.MethodPatch) r.HandleFunc("/branch/{branchName}", authMW.Authorized(s.deleteBranch)).Methods(http.MethodDelete) // Sub-route /admin diff --git a/engine/internal/telemetry/events.go b/engine/internal/telemetry/events.go index 82b6f54c..14fd200a 100644 --- a/engine/internal/telemetry/events.go +++ b/engine/internal/telemetry/events.go @@ -70,6 +70,18 @@ type BranchDestroyed struct { Name string `json:"name"` } +// BranchUpdated describes a branch update event. +type BranchUpdated struct { + Name string `json:"name"` + Protected bool `json:"protected"` +} + +// SnapshotUpdated describes a snapshot update event. +type SnapshotUpdated struct { + ID string `json:"id"` + Protected bool `json:"protected"` +} + // ConfigUpdated describes the config updates. type ConfigUpdated struct{} diff --git a/engine/internal/telemetry/telemetry.go b/engine/internal/telemetry/telemetry.go index 5feeb3fa..be07ae10 100644 --- a/engine/internal/telemetry/telemetry.go +++ b/engine/internal/telemetry/telemetry.go @@ -41,6 +41,12 @@ const ( // BranchDestroyedEvent describes a branch destruction event. BranchDestroyedEvent = "branch_destroyed" + // BranchUpdatedEvent describes a branch update event. + BranchUpdatedEvent = "branch_updated" + + // SnapshotUpdatedEvent describes a snapshot update event. + SnapshotUpdatedEvent = "snapshot_updated" + ConfigUpdatedEvent = "config_updated" // AlertEvent describes alert events. diff --git a/engine/pkg/client/dblabapi/clone_test.go b/engine/pkg/client/dblabapi/clone_test.go index 27dc9376..ba4183c6 100644 --- a/engine/pkg/client/dblabapi/clone_test.go +++ b/engine/pkg/client/dblabapi/clone_test.go @@ -366,7 +366,9 @@ func TestClientUpdateClone(t *testing.T) { err = json.Unmarshal(requestBody, &updateRequest) require.NoError(t, err) - cloneModel.Protected = updateRequest.Protected + if updateRequest.Protected != nil { + cloneModel.Protected = *updateRequest.Protected + } // Prepare response. responseBody, err := json.Marshal(cloneModel) @@ -388,8 +390,9 @@ func TestClientUpdateClone(t *testing.T) { c.client = mockClient // Send a request. + protectedFalse := false newClone, err := c.UpdateClone(context.Background(), cloneModel.ID, types.CloneUpdateRequest{ - Protected: false, + Protected: &protectedFalse, }) require.NoError(t, err) diff --git a/engine/pkg/client/dblabapi/types/clone.go b/engine/pkg/client/dblabapi/types/clone.go index 442d5e22..cb2c1fde 100644 --- a/engine/pkg/client/dblabapi/types/clone.go +++ b/engine/pkg/client/dblabapi/types/clone.go @@ -7,18 +7,22 @@ package types // CloneCreateRequest represents clone params of a create request. type CloneCreateRequest struct { - ID string `json:"id"` - Protected bool `json:"protected"` - DB *DatabaseRequest `json:"db"` - Snapshot *SnapshotCloneFieldRequest `json:"snapshot"` - ExtraConf map[string]string `json:"extra_conf"` - Branch string `json:"branch"` - Revision int `json:"-"` + ID string `json:"id"` + Protected bool `json:"protected"` + DeleteAt string `json:"deleteAt,omitempty"` + AutoDeleteMode int `json:"autoDeleteMode,omitempty"` + DB *DatabaseRequest `json:"db"` + Snapshot *SnapshotCloneFieldRequest `json:"snapshot"` + ExtraConf map[string]string `json:"extra_conf"` + Branch string `json:"branch"` + Revision int `json:"-"` } // CloneUpdateRequest represents params of an update request. type CloneUpdateRequest struct { - Protected bool `json:"protected"` + Protected *bool `json:"protected,omitempty"` + DeleteAt *string `json:"deleteAt,omitempty"` + AutoDeleteMode *int `json:"autoDeleteMode,omitempty"` } // DatabaseRequest represents database params of a clone request. @@ -59,9 +63,19 @@ type SnapshotCloneCreateRequest struct { // BranchCreateRequest describes params for creating branch request. type BranchCreateRequest struct { - BranchName string `json:"branchName"` - BaseBranch string `json:"baseBranch"` - SnapshotID string `json:"snapshotID"` + BranchName string `json:"branchName"` + BaseBranch string `json:"baseBranch"` + SnapshotID string `json:"snapshotID"` + Protected bool `json:"protected,omitempty"` + DeleteAt string `json:"deleteAt,omitempty"` + AutoDeleteMode int `json:"autoDeleteMode,omitempty"` +} + +// BranchUpdateRequest describes params for updating branch request. +type BranchUpdateRequest struct { + Protected *bool `json:"protected,omitempty"` + DeleteAt *string `json:"deleteAt,omitempty"` + AutoDeleteMode *int `json:"autoDeleteMode,omitempty"` } // SnapshotResponse describes commit response. @@ -83,3 +97,10 @@ type LogRequest struct { type BranchDeleteRequest struct { BranchName string `json:"branchName"` } + +// SnapshotUpdateRequest describes params for updating snapshot request. +type SnapshotUpdateRequest struct { + Protected *bool `json:"protected,omitempty"` + DeleteAt *string `json:"deleteAt,omitempty"` + AutoDeleteMode *int `json:"autoDeleteMode,omitempty"` +} diff --git a/engine/pkg/models/auto_delete.go b/engine/pkg/models/auto_delete.go new file mode 100644 index 00000000..d9e0cd40 --- /dev/null +++ b/engine/pkg/models/auto_delete.go @@ -0,0 +1,37 @@ +/* +2024 © Postgres.ai +*/ + +package models + +// AutoDeleteMode defines the auto-deletion behavior for entities. +type AutoDeleteMode int + +// Auto-deletion mode constants. +const ( + // AutoDeleteOff disables auto-deletion. + AutoDeleteOff AutoDeleteMode = 0 + // AutoDeleteSoft enables auto-deletion only if no dependencies exist. + AutoDeleteSoft AutoDeleteMode = 1 + // AutoDeleteForce enables auto-deletion with recursive destroy of dependencies. + AutoDeleteForce AutoDeleteMode = 2 +) + +// String returns the string representation of the auto-delete mode. +func (m AutoDeleteMode) String() string { + switch m { + case AutoDeleteOff: + return "off" + case AutoDeleteSoft: + return "soft" + case AutoDeleteForce: + return "force" + default: + return "unknown" + } +} + +// IsValid checks if the auto-delete mode value is valid. +func (m AutoDeleteMode) IsValid() bool { + return m >= AutoDeleteOff && m <= AutoDeleteForce +} diff --git a/engine/pkg/models/branch.go b/engine/pkg/models/branch.go index dcdf4203..711a0958 100644 --- a/engine/pkg/models/branch.go +++ b/engine/pkg/models/branch.go @@ -2,7 +2,10 @@ package models // Branch defines a branch entity. type Branch struct { - Name string `json:"name"` + Name string `json:"name"` + Protected bool `json:"protected"` + DeleteAt *LocalTime `json:"deleteAt,omitempty"` + AutoDeleteMode AutoDeleteMode `json:"autoDeleteMode"` } // Repo describes data repository with details about snapshots and branches. @@ -34,13 +37,16 @@ type SnapshotDetails struct { // BranchView describes branch view. type BranchView struct { - Name string `json:"name"` - BaseDataset string `json:"baseDataset"` - Parent string `json:"parent"` - DataStateAt string `json:"dataStateAt"` - SnapshotID string `json:"snapshotID"` - Dataset string `json:"dataset"` - NumSnapshots int `json:"numSnapshots"` + Name string `json:"name"` + Protected bool `json:"protected"` + DeleteAt *LocalTime `json:"deleteAt,omitempty"` + AutoDeleteMode AutoDeleteMode `json:"autoDeleteMode"` + BaseDataset string `json:"baseDataset"` + Parent string `json:"parent"` + DataStateAt string `json:"dataStateAt"` + SnapshotID string `json:"snapshotID"` + Dataset string `json:"dataset"` + NumSnapshots int `json:"numSnapshots"` } // BranchEntity defines a branch-snapshot pair. diff --git a/engine/pkg/models/clone.go b/engine/pkg/models/clone.go index da6e4d1c..91c997f5 100644 --- a/engine/pkg/models/clone.go +++ b/engine/pkg/models/clone.go @@ -6,16 +6,17 @@ package models // Clone defines a clone model. type Clone struct { - ID string `json:"id"` - Snapshot *Snapshot `json:"snapshot"` - Branch string `json:"branch"` - Revision int `json:"revision"` - Protected bool `json:"protected"` - DeleteAt *LocalTime `json:"deleteAt"` - CreatedAt *LocalTime `json:"createdAt"` - Status Status `json:"status"` - DB Database `json:"db"` - Metadata CloneMetadata `json:"metadata"` + ID string `json:"id"` + Snapshot *Snapshot `json:"snapshot"` + Branch string `json:"branch"` + Revision int `json:"revision"` + Protected bool `json:"protected"` + DeleteAt *LocalTime `json:"deleteAt,omitempty"` + AutoDeleteMode AutoDeleteMode `json:"autoDeleteMode"` + CreatedAt *LocalTime `json:"createdAt"` + Status Status `json:"status"` + DB Database `json:"db"` + Metadata CloneMetadata `json:"metadata"` } // CloneMetadata contains fields describing a clone model. diff --git a/engine/pkg/models/local_time.go b/engine/pkg/models/local_time.go index 095edd12..9c977c40 100644 --- a/engine/pkg/models/local_time.go +++ b/engine/pkg/models/local_time.go @@ -52,3 +52,20 @@ func (t *LocalTime) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprintf("%q", t.Local().Format(time.RFC3339))), nil } + +// ParseLocalTime parses a string into LocalTime. +func ParseLocalTime(s string) (*LocalTime, error) { + if s == "" { + return nil, nil + } + + parsedTime, err := time.Parse(time.RFC3339, s) + if err != nil { + parsedTime, err = time.Parse(legacyFormat, s) + if err != nil { + return nil, fmt.Errorf("failed to parse time: %w", err) + } + } + + return &LocalTime{Time: parsedTime}, nil +} diff --git a/engine/pkg/models/snapshot.go b/engine/pkg/models/snapshot.go index 5299e4ad..35a5ff77 100644 --- a/engine/pkg/models/snapshot.go +++ b/engine/pkg/models/snapshot.go @@ -6,16 +6,19 @@ package models // Snapshot defines a snapshot entity. type Snapshot struct { - ID string `json:"id"` - CreatedAt *LocalTime `json:"createdAt"` - DataStateAt *LocalTime `json:"dataStateAt"` - PhysicalSize uint64 `json:"physicalSize"` - LogicalSize uint64 `json:"logicalSize"` - Pool string `json:"pool"` - NumClones int `json:"numClones"` - Clones []string `json:"clones"` - Branch string `json:"branch"` - Message string `json:"message"` + ID string `json:"id"` + CreatedAt *LocalTime `json:"createdAt"` + DataStateAt *LocalTime `json:"dataStateAt"` + PhysicalSize uint64 `json:"physicalSize"` + LogicalSize uint64 `json:"logicalSize"` + Pool string `json:"pool"` + NumClones int `json:"numClones"` + Clones []string `json:"clones"` + Branch string `json:"branch"` + Message string `json:"message"` + Protected bool `json:"protected"` + DeleteAt *LocalTime `json:"deleteAt,omitempty"` + AutoDeleteMode AutoDeleteMode `json:"autoDeleteMode"` } // SnapshotView represents a view of snapshot.