268 lines
7.0 KiB
Go
268 lines
7.0 KiB
Go
|
|
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
|
||
|
|
}
|