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
}