internal/gitlabnet/lfstransfer/client.go (300 lines of code) (raw):

// 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 }