gitlabhq/gitlab-shell

View on GitHub
internal/gitlabnet/lfstransfer/client.go

Summary

Maintainability
A
2 hrs
Test Coverage
// Package lfstransfer provides functionality for handling LFS (Large File Storage) transfers.
package lfstransfer

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "io/fs"
    "net/http"
    "net/url"
    "time"

    "github.com/charmbracelet/git-lfs-transfer/transfer"
    "github.com/hashicorp/go-retryablehttp"
    "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs"
    "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
    "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet"
)

// Client holds configuration, arguments, and authentication details for the client.
type Client struct {
    config *config.Config
    args   *commandargs.Shell
    href   string
    auth   string
    header string
}

// BatchAction represents an action for a batch operation with metadata.
type BatchAction struct {
    Href      string            `json:"href"`
    Header    map[string]string `json:"header,omitempty"`
    ExpiresAt time.Time         `json:"expires_at,omitempty"`
    ExpiresIn int               `json:"expires_in,omitempty"`
}

// BatchObject represents an object in a batch operation with its metadata and actions.
type BatchObject struct {
    Oid           string                  `json:"oid,omitempty"`
    Size          int64                   `json:"size"`
    Authenticated bool                    `json:"authenticated,omitempty"`
    Actions       map[string]*BatchAction `json:"actions,omitempty"`
}

type batchRef struct {
    Name string `json:"name,omitempty"`
}

type batchRequest struct {
    Operation     string         `json:"operation"`
    Objects       []*BatchObject `json:"objects"`
    Ref           *batchRef      `json:"ref,omitempty"`
    HashAlgorithm string         `json:"hash_algo,omitempty"`
}

// BatchResponse contains batch operation results and the hash algorithm used.
type BatchResponse struct {
    Objects       []*BatchObject `json:"objects"`
    HashAlgorithm string         `json:"hash_algo,omitempty"`
}

type lockRequest struct {
    Path string    `json:"path"`
    Ref  *batchRef `json:"ref,omitempty"`
}

type lockResponse struct {
    Lock *Lock `json:"lock"`
}

type unlockRequest struct {
    Force bool      `json:"force"`
    Ref   *batchRef `json:"ref,omitempty"`
}

type unlockResponse struct {
    Lock *Lock `json:"lock"`
}

type listLocksVerifyRequest struct {
    Cursor string    `json:"cursor,omitempty"`
    Limit  int       `json:"limit"`
    Ref    *batchRef `json:"ref,omitempty"`
}

// LockOwner represents the owner of a lock.
type LockOwner struct {
    Name string `json:"name"`
}

// Lock represents a lock with its ID, path, timestamp, and owner details.
type Lock struct {
    ID       string     `json:"id"`
    Path     string     `json:"path"`
    LockedAt time.Time  `json:"locked_at"`
    Owner    *LockOwner `json:"owner"`
}

// ListLocksResponse contains a list of locks and a cursor for pagination.
type ListLocksResponse struct {
    Locks      []*Lock `json:"locks,omitempty"`
    NextCursor string  `json:"next_cursor,omitempty"`
}

// ListLocksVerifyResponse provides lists of locks for "ours" and "theirs" with a cursor for pagination.
type ListLocksVerifyResponse struct {
    Ours       []*Lock `json:"ours,omitempty"`
    Theirs     []*Lock `json:"theirs,omitempty"`
    NextCursor string  `json:"next_cursor,omitempty"`
}

// ClientHeader specifies the content type for Git LFS JSON requests.
var ClientHeader = "application/vnd.git-lfs+json"

// NewClient creates a new Client instance using the provided configuration and credentials.
func NewClient(config *config.Config, args *commandargs.Shell, href string, auth string) (*Client, error) {
    return &Client{config: config, args: args, href: href, auth: auth, header: ClientHeader}, nil
}
func newHTTPRequest(method string, ref string, reader io.Reader) (*retryablehttp.Request, error) {
    req, err := retryablehttp.NewRequest(method, ref, reader)
    if err != nil {
        return nil, err
    }
    return req, nil
}

func newHTTPClient() *retryablehttp.Client {
    client := retryablehttp.NewClient()
    client.RetryMax = 3
    client.Logger = nil
    return client
}

// Batch performs a batch operation on objects and returns the result.
func (c *Client) Batch(operation string, reqObjects []*BatchObject, ref string, reqHashAlgo string) (*BatchResponse, error) {
    // FIXME: This causes tests to fail
    // if ref == "" {
    //     return nil, errors.New("A ref must be specified.")
    // }

    bref := &batchRef{Name: ref}
    body := batchRequest{
        Operation:     operation,
        Objects:       reqObjects,
        Ref:           bref,
        HashAlgorithm: reqHashAlgo,
    }

    jsonData, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }

    jsonReader := bytes.NewReader(jsonData)

    req, _ := newHTTPRequest(http.MethodPost, fmt.Sprintf("%s/objects/batch", c.href), jsonReader)

    req.Header.Set("Content-Type", c.header)
    req.Header.Set("Authorization", c.auth)
    client := newHTTPClient()

    res, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    // Error condition taken from example: https://pkg.go.dev/net/http#example-Get
    if res.StatusCode > 399 {
        return nil, fmt.Errorf("response failed with status code: %d", res.StatusCode)
    }

    defer func() { _ = res.Body.Close() }()

    response := &BatchResponse{}
    if err := gitlabnet.ParseJSON(res, response); err != nil {
        return nil, err
    }

    return response, nil
}

// GetObject performs an HTTP GET request for the object
func (c *Client) GetObject(_, href string, headers map[string]string) (io.ReadCloser, int64, error) {
    req, _ := newHTTPRequest(http.MethodGet, href, nil)
    for key, value := range headers {
        req.Header.Add(key, value)
    }

    client := newHTTPClient()
    // See https://gitlab.com/gitlab-org/gitlab-shell/-/merge_requests/989#note_1891153531 for
    // discussion on bypassing the linter
    res, err := client.Do(req) // nolint:bodyclose
    if err != nil {
        return nil, 0, err
    }
    if res.StatusCode < 200 || res.StatusCode > 299 {
        return nil, 0, fs.ErrNotExist
    }

    return res.Body, res.ContentLength, nil
}

// PutObject performs an HTTP PUT request for the object
func (c *Client) PutObject(_, href string, headers map[string]string, r io.Reader) error {
    req, _ := newHTTPRequest(http.MethodPut, href, r)
    for key, value := range headers {
        req.Header.Add(key, value)
    }

    client := newHTTPClient()
    res, err := client.Do(req)
    if err != nil {
        return err
    }
    defer func() { _ = res.Body.Close() }()
    if res.StatusCode == 404 {
        return transfer.ErrNotFound
    }
    if res.StatusCode < 200 || res.StatusCode > 299 {
        return fmt.Errorf("internal error (%d)", res.StatusCode)
    }
    return nil
}

// Lock acquires a lock for the specified path with an optional reference name.
func (c *Client) Lock(path, refname string) (*Lock, error) {
    var ref *batchRef
    if refname != "" {
        ref = &batchRef{
            Name: refname,
        }
    }
    body := &lockRequest{
        Path: path,
        Ref:  ref,
    }
    jsonData, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }
    jsonReader := bytes.NewReader(jsonData)

    req, err := newHTTPRequest(http.MethodPost, c.href+"/locks", jsonReader)
    if err != nil {
        return nil, err
    }

    req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
    req.Header.Set("Authorization", c.auth)

    client := newHTTPClient()
    res, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    defer func() { _ = res.Body.Close() }()

    switch {
    case res.StatusCode >= 200 && res.StatusCode <= 299:
        response := &lockResponse{}
        if err := gitlabnet.ParseJSON(res, response); err != nil {
            return nil, err
        }

        return response.Lock, nil
    case res.StatusCode == http.StatusForbidden:
        return nil, transfer.ErrForbidden
    case res.StatusCode == http.StatusNotFound:
        return nil, transfer.ErrNotFound
    case res.StatusCode == http.StatusConflict:
        response := &lockResponse{}
        if err := gitlabnet.ParseJSON(res, response); err != nil {
            return nil, err
        }

        return response.Lock, transfer.ErrConflict
    default:
        return nil, fmt.Errorf("internal error")
    }
}

// Unlock releases the lock with the given id, optionally forcing the unlock.
func (c *Client) Unlock(id string, force bool, refname string) (*Lock, error) {
    var ref *batchRef
    if refname != "" {
        ref = &batchRef{
            Name: refname,
        }
    }
    body := &unlockRequest{
        Force: force,
        Ref:   ref,
    }
    jsonData, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }
    jsonReader := bytes.NewReader(jsonData)

    req, err := newHTTPRequest(http.MethodPost, fmt.Sprintf("%s/locks/%s/unlock", c.href, id), jsonReader)
    if err != nil {
        return nil, err
    }

    req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
    req.Header.Set("Authorization", c.auth)

    client := newHTTPClient()
    res, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    defer func() { _ = res.Body.Close() }()

    switch {
    case res.StatusCode >= 200 && res.StatusCode <= 299:
        response := &unlockResponse{}
        if err := gitlabnet.ParseJSON(res, response); err != nil {
            return nil, err
        }

        return response.Lock, nil
    case res.StatusCode == http.StatusForbidden:
        return nil, transfer.ErrForbidden
    case res.StatusCode == http.StatusNotFound:
        return nil, transfer.ErrNotFound
    default:
        return nil, fmt.Errorf("internal error")
    }
}

// ListLocksVerify retrieves locks for the given path and id, with optional pagination.
func (c *Client) ListLocksVerify(path, id, cursor string, limit int, ref string) (*ListLocksVerifyResponse, error) {
    url, err := url.Parse(c.href)
    if err != nil {
        return nil, err
    }
    url = url.JoinPath("locks/verify")
    query := url.Query()
    if path != "" {
        query.Add("path", path)
    }
    if id != "" {
        query.Add("id", id)
    }
    url.RawQuery = query.Encode()

    body := listLocksVerifyRequest{
        Cursor: cursor,
        Limit:  limit,
        Ref: &batchRef{
            Name: ref,
        },
    }
    jsonData, err := json.Marshal(&body)
    if err != nil {
        return nil, err
    }
    jsonReader := bytes.NewReader(jsonData)

    req, _ := newHTTPRequest(http.MethodPost, url.String(), jsonReader)
    req.Header.Set("Content-Type", c.header)
    req.Header.Set("Authorization", c.auth)

    client := newHTTPClient()
    res, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    defer func() { _ = res.Body.Close() }()

    response := &ListLocksVerifyResponse{}
    if err := gitlabnet.ParseJSON(res, response); err != nil {
        return nil, err
    }

    return response, nil
}