Files
backup-master/internal/api/api.go
T
mrleo1nidandClaude Opus 5.5 488abc2d34
CI / test (push) Successful in 1m29s
Агенты подхватывают изменения с сервера: каталог и самообновление
- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера +
  переопределения клиента); если каталог не подходит версии агента —
  встроенный каталог агента и предупреждение
- самообновление перед бэкапом: версия сервера новее → скачивание с того же
  сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена
  с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией
- галочка «Автообновление» у клиента (по умолчанию включена), версия агента
  в списке и карточке, отставшие подсвечены; версия сервера в шапке
- миграция существующей базы: колонки auto_update и agent_version
- backupctl backup --no-update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:27:31 +03:00

204 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api — типы протокола сервер↔агент/restore и HTTP-клиент к нему.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type AgentConfig struct {
Domain string `json:"domain"`
ConfigYAML string `json:"config_yaml"` // только переопределения клиента (для старых агентов)
HeavyDue bool `json:"heavy_due"`
// CatalogYAML — итоговый каталог: встроенный каталог сервера + переопределения клиента.
// Агент берёт его вместо своего встроенного, поэтому новые категории приходят без обновления агента.
CatalogYAML string `json:"catalog_yaml,omitempty"`
ServerVersion string `json:"server_version,omitempty"`
Update *AgentUpdate `json:"update,omitempty"` // есть, если агенту пора обновиться
}
// AgentUpdate — новая версия агента. Path — путь на том же сервере (/dl/...), чужие адреса агент не принимает.
type AgentUpdate struct {
Version string `json:"version"`
Path string `json:"path"`
SHA256 string `json:"sha256"`
}
type PartInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type CompleteRequest struct {
Parts []PartInfo `json:"parts"`
// Warnings — предупреждения, возникшие при архивации (после отправки манифеста).
Warnings []string `json:"warnings,omitempty"`
}
type RestoreManifest struct {
BackupID int64 `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
Manifest json.RawMessage `json:"manifest"`
Parts []PartInfo `json:"parts"` // только доступные (heavy могли удалить ротацией)
}
// Client — HTTP-клиент с Bearer-токеном.
type Client struct {
Server string
Token string
HTTP *http.Client
}
func NewClient(server, token string) *Client {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 10 * time.Minute, // complete на сервере может считать ретеншн
ForceAttemptHTTP2: true,
}
return &Client{Server: strings.TrimRight(server, "/"), Token: token, HTTP: &http.Client{Transport: tr}}
}
type Error struct {
Status int
Msg string
}
func (e *Error) Error() string {
return fmt.Sprintf("сервер ответил %d: %s", e.Status, e.Msg)
}
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, c.Server+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil && method != http.MethodPut {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
var e struct{ Error string }
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if json.Unmarshal(data, &e) != nil || e.Error == "" {
e.Error = strings.TrimSpace(string(data))
}
return &Error{Status: resp.StatusCode, Msg: e.Error}
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func jsonBody(v any) io.Reader {
data, _ := json.Marshal(v)
return bytes.NewReader(data)
}
// AgentConfig запрашивает настройки агента; arch и version нужны серверу, чтобы предложить обновление.
func (c *Client) AgentConfig(ctx context.Context, arch, version string) (*AgentConfig, error) {
var out AgentConfig
q := url.Values{}
if arch != "" {
q.Set("arch", arch)
}
if version != "" {
q.Set("version", version)
}
path := "/api/v1/agent/config"
if len(q) > 0 {
path += "?" + q.Encode()
}
return &out, c.do(ctx, http.MethodGet, path, nil, &out)
}
// Download открывает поток файла с этого же сервера (path вида /dl/...).
func (c *Client) Download(ctx context.Context, path string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Server+path, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, &Error{Status: resp.StatusCode, Msg: resp.Status}
}
return resp.Body, nil
}
func (c *Client) CreateBackup(ctx context.Context, m *manifest.Manifest) (int64, error) {
var out struct{ ID int64 }
err := c.do(ctx, http.MethodPost, "/api/v1/backups", jsonBody(m), &out)
return out.ID, err
}
func (c *Client) UploadPart(ctx context.Context, id int64, name string, body io.Reader) (*PartInfo, error) {
var out PartInfo
err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/backups/%d/parts/%s", id, name), body, &out)
return &out, err
}
func (c *Client) Complete(ctx context.Context, id int64, req CompleteRequest) error {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/complete", id), jsonBody(req), nil)
}
func (c *Client) Fail(ctx context.Context, id int64, msg string) error {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/fail", id), jsonBody(map[string]string{"error": msg}), nil)
}
func (c *Client) RestoreManifest(ctx context.Context, backupID int64) (*RestoreManifest, error) {
path := "/api/v1/restore/manifest"
if backupID != 0 {
path += fmt.Sprintf("?backup=%d", backupID)
}
var out RestoreManifest
return &out, c.do(ctx, http.MethodGet, path, nil, &out)
}
// DownloadPart открывает поток части начиная с offset (для докачки).
func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset int64) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/restore/%d/parts/%s", c.Server, id, name), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
want := http.StatusOK
if offset > 0 {
want = http.StatusPartialContent
}
if resp.StatusCode != want {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
return nil, &Error{Status: resp.StatusCode, Msg: strings.TrimSpace(string(data))}
}
return resp.Body, nil
}