package k8s import ( "context" "fmt" "sort" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" ) const longhornSystemNamespace = "longhorn-system" var longhornBackupGVR = schema.GroupVersionResource{ Group: "longhorn.io", Version: "v1beta2", Resource: "backups", } // LonghornBackupSummary is the subset of the Longhorn Backup CRD that Soteria // needs while waiting for an async backup to reach a terminal state. type LonghornBackupSummary struct { Name string SnapshotName string Namespace string PVC string State string Error string Progress int64 CreatedAt string } // ListLonghornBackups returns Soteria-owned Longhorn Backup CRDs. func (c *Client) ListLonghornBackups(ctx context.Context) ([]LonghornBackupSummary, error) { if c.Dynamic == nil { return nil, fmt.Errorf("dynamic Kubernetes client is unavailable") } list, err := c.Dynamic.Resource(longhornBackupGVR).Namespace(longhornSystemNamespace).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("list Longhorn backups: %w", err) } items := make([]LonghornBackupSummary, 0, len(list.Items)) for _, item := range list.Items { summary := summarizeLonghornBackup(item) if summary.Namespace != "" && summary.PVC != "" { items = append(items, summary) } } sortLonghornBackupsNewestFirst(items) return items, nil } // GetLonghornBackupBySnapshot returns the newest Longhorn Backup CRD for a snapshot. func (c *Client) GetLonghornBackupBySnapshot(ctx context.Context, snapshotName string) (LonghornBackupSummary, bool, error) { snapshotName = strings.TrimSpace(snapshotName) if snapshotName == "" { return LonghornBackupSummary{}, false, fmt.Errorf("snapshot name is required") } if c.Dynamic == nil { return LonghornBackupSummary{}, false, fmt.Errorf("dynamic Kubernetes client is unavailable") } list, err := c.Dynamic.Resource(longhornBackupGVR).Namespace(longhornSystemNamespace).List(ctx, metav1.ListOptions{}) if err != nil { return LonghornBackupSummary{}, false, fmt.Errorf("list Longhorn backups: %w", err) } matches := make([]LonghornBackupSummary, 0, len(list.Items)) for _, item := range list.Items { summary := summarizeLonghornBackup(item) if summary.SnapshotName == snapshotName { matches = append(matches, summary) } } if len(matches) == 0 { return LonghornBackupSummary{}, false, nil } sortLonghornBackupsNewestFirst(matches) return matches[0], true, nil } func summarizeLonghornBackup(item unstructured.Unstructured) LonghornBackupSummary { snapshotName, _, _ := unstructured.NestedString(item.Object, "spec", "snapshotName") state, _, _ := unstructured.NestedString(item.Object, "status", "state") errorMessage, _, _ := unstructured.NestedString(item.Object, "status", "error") progress, _, _ := unstructured.NestedInt64(item.Object, "status", "progress") specLabels, _, _ := unstructured.NestedStringMap(item.Object, "spec", "labels") createdAt, _, _ := unstructured.NestedString(item.Object, "status", "backupCreatedAt") if strings.TrimSpace(createdAt) == "" { createdAt, _, _ = unstructured.NestedString(item.Object, "status", "snapshotCreatedAt") } if strings.TrimSpace(createdAt) == "" { createdAt = item.GetCreationTimestamp().UTC().Format("2006-01-02T15:04:05Z") } return LonghornBackupSummary{ Name: item.GetName(), SnapshotName: strings.TrimSpace(snapshotName), Namespace: strings.TrimSpace(specLabels["soteria.bstein.dev/namespace"]), PVC: strings.TrimSpace(specLabels["soteria.bstein.dev/pvc"]), State: strings.TrimSpace(state), Error: strings.TrimSpace(errorMessage), Progress: progress, CreatedAt: strings.TrimSpace(createdAt), } } func sortLonghornBackupsNewestFirst(items []LonghornBackupSummary) { sort.Slice(items, func(i, j int) bool { if items[i].CreatedAt == items[j].CreatedAt { return items[i].Name > items[j].Name } return items[i].CreatedAt > items[j].CreatedAt }) }