package longhorn import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) const defaultTimeout = 30 * time.Second type Client struct { baseURL string http *http.Client } func New(baseURL string) *Client { baseURL = strings.TrimSuffix(baseURL, "/") return &Client{ baseURL: baseURL, http: &http.Client{ Timeout: defaultTimeout, }, } } type APIError struct { Status int Message string } func (e *APIError) Error() string { return fmt.Sprintf("longhorn api error: status=%d message=%s", e.Status, e.Message) } type Volume struct { Name string `json:"name"` Size string `json:"size"` NumberOfReplicas int `json:"numberOfReplicas"` BackupStatus []BackupStatus `json:"backupStatus"` LastBackup string `json:"lastBackup"` LastBackupAt string `json:"lastBackupAt"` Actions map[string]any `json:"actions"` } type BackupStatus struct { Snapshot string `json:"snapshot"` BackupURL string `json:"backupURL"` State string `json:"state"` Error string `json:"error"` Progress int `json:"progress"` } type BackupVolume struct { Name string `json:"name"` Actions map[string]string `json:"actions"` } type Backup struct { Name string `json:"name"` SnapshotName string `json:"snapshotName"` VolumeName string `json:"volumeName"` Created string `json:"created"` State string `json:"state"` URL string `json:"url"` Size string `json:"size"` } type backupListOutput struct { Data []Backup `json:"data"` } type backupVolumeListOutput struct { Data []BackupVolume `json:"data"` } type snapshotInput struct { Name string `json:"name,omitempty"` Labels map[string]string `json:"labels,omitempty"` BackupMode string `json:"backupMode,omitempty"` } type pvcCreateInput struct { Namespace string `json:"namespace"` PVCName string `json:"pvcName"` } func (c *Client) SnapshotBackup(ctx context.Context, volume, name string, labels map[string]string, backupMode string) (*Volume, error) { path := fmt.Sprintf("%s/v1/volumes/%s?action=snapshotBackup", c.baseURL, url.PathEscape(volume)) input := snapshotInput{ Name: name, Labels: labels, } if backupMode != "" { input.BackupMode = backupMode } var out Volume if err := c.doJSON(ctx, http.MethodPost, path, input, &out); err != nil { return nil, err } return &out, nil } func (c *Client) GetVolume(ctx context.Context, volume string) (*Volume, error) { path := fmt.Sprintf("%s/v1/volumes/%s", c.baseURL, url.PathEscape(volume)) var out Volume if err := c.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil { return nil, err } return &out, nil } func (c *Client) CreateVolumeFromBackup(ctx context.Context, name, size string, replicas int, backupURL string) (*Volume, error) { path := fmt.Sprintf("%s/v1/volumes", c.baseURL) payload := map[string]any{ "name": name, "size": size, "fromBackup": backupURL, "dataEngine": "v1", } if replicas > 0 { payload["numberOfReplicas"] = replicas } var out Volume if err := c.doJSON(ctx, http.MethodPost, path, payload, &out); err != nil { return nil, err } return &out, nil } func (c *Client) CreatePVC(ctx context.Context, volumeName, namespace, pvcName string) error { path := fmt.Sprintf("%s/v1/volumes/%s?action=pvcCreate", c.baseURL, url.PathEscape(volumeName)) input := pvcCreateInput{ Namespace: namespace, PVCName: pvcName, } return c.doJSON(ctx, http.MethodPost, path, input, nil) } func (c *Client) GetBackupVolume(ctx context.Context, volumeName string) (*BackupVolume, error) { path := fmt.Sprintf("%s/v1/backupvolumes/%s", c.baseURL, url.PathEscape(volumeName)) var out BackupVolume if err := c.doJSON(ctx, http.MethodGet, path, nil, &out); err == nil { return &out, nil } else if apiErr, ok := err.(*APIError); ok && apiErr.Status == http.StatusNotFound { listPath := fmt.Sprintf("%s/v1/backupvolumes", c.baseURL) var list backupVolumeListOutput if listErr := c.doJSON(ctx, http.MethodGet, listPath, nil, &list); listErr != nil { return nil, listErr } for _, item := range list.Data { if item.Name == volumeName { return &item, nil } } return nil, fmt.Errorf("backup volume %s not found", volumeName) } else { return nil, err } } func (c *Client) ListBackups(ctx context.Context, volumeName string) ([]Backup, error) { backupVolume, err := c.GetBackupVolume(ctx, volumeName) if err != nil { return nil, err } listURL, ok := backupVolume.Actions["backupList"] if !ok || listURL == "" { return nil, fmt.Errorf("backup list action missing for volume %s", volumeName) } var out backupListOutput if err := c.doJSON(ctx, http.MethodPost, listURL, map[string]any{}, &out); err != nil { return nil, err } return out.Data, nil } func (c *Client) FindBackup(ctx context.Context, volumeName, snapshot string) (*Backup, error) { backups, err := c.ListBackups(ctx, volumeName) if err != nil { return nil, err } if len(backups) == 0 { return nil, fmt.Errorf("no backups found for volume %s", volumeName) } if snapshot != "" && snapshot != "latest" { for _, backup := range backups { if backup.Name == snapshot || backup.SnapshotName == snapshot || backup.URL == snapshot { return &backup, nil } } return nil, fmt.Errorf("backup %s not found for volume %s", snapshot, volumeName) } var selected *Backup var selectedTime time.Time for _, backup := range backups { if backup.State != "Completed" { continue } createdAt, err := time.Parse(time.RFC3339, backup.Created) if err != nil { if selected == nil { candidate := backup selected = &candidate } continue } if selected == nil || createdAt.After(selectedTime) { candidate := backup selected = &candidate selectedTime = createdAt } } if selected == nil { return nil, fmt.Errorf("no completed backups found for volume %s", volumeName) } return selected, nil } func (c *Client) doJSON(ctx context.Context, method, url string, payload any, out any) error { var body io.Reader if payload != nil { data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("encode request: %w", err) } body = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return fmt.Errorf("build request: %w", err) } if payload != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { msg := strings.TrimSpace(string(respBody)) return &APIError{Status: resp.StatusCode, Message: msg} } if out == nil || len(respBody) == 0 { return nil } if err := json.Unmarshal(respBody, out); err != nil { return fmt.Errorf("decode response: %w", err) } return nil }