Add producer upload client package
This commit is contained in:
104
pkg/upload/archive.go
Normal file
104
pkg/upload/archive.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
)
|
||||
|
||||
func archiveBundle(root string, manifest bundle.Manifest) ([]byte, error) {
|
||||
var output bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&output)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
|
||||
manifestData, err := bundle.MarshalManifest(manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeTarEntry(tarWriter, bundle.ManifestName, manifestData, 0o600, manifest.Created); err != nil {
|
||||
_ = tarWriter.Close()
|
||||
_ = gzipWriter.Close()
|
||||
return nil, err
|
||||
}
|
||||
for _, manifestFile := range manifest.Files {
|
||||
fullPath := filepath.Join(root, filepath.FromSlash(manifestFile.Path))
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err != nil {
|
||||
_ = tarWriter.Close()
|
||||
_ = gzipWriter.Close()
|
||||
return nil, fmt.Errorf("file %q stat: %w", manifestFile.Path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = tarWriter.Close()
|
||||
_ = gzipWriter.Close()
|
||||
return nil, fmt.Errorf("file %q must be a regular file", manifestFile.Path)
|
||||
}
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
_ = tarWriter.Close()
|
||||
_ = gzipWriter.Close()
|
||||
return nil, fmt.Errorf("file %q open: %w", manifestFile.Path, err)
|
||||
}
|
||||
if err := writeTarFile(tarWriter, manifestFile.Path, file, info.Mode().Perm(), info.ModTime(), info.Size()); err != nil {
|
||||
_ = file.Close()
|
||||
_ = tarWriter.Close()
|
||||
_ = gzipWriter.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = tarWriter.Close()
|
||||
_ = gzipWriter.Close()
|
||||
return nil, fmt.Errorf("file %q close: %w", manifestFile.Path, err)
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
_ = gzipWriter.Close()
|
||||
return nil, fmt.Errorf("close tar archive: %w", err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close gzip archive: %w", err)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeTarEntry(writer *tar.Writer, name string, data []byte, mode int64, modTime time.Time) error {
|
||||
header := &tar.Header{
|
||||
Name: name,
|
||||
Mode: mode,
|
||||
Size: int64(len(data)),
|
||||
ModTime: modTime,
|
||||
}
|
||||
if err := writer.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write tar header %q: %w", name, err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
return fmt.Errorf("write tar entry %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTarFile(writer *tar.Writer, name string, file *os.File, mode os.FileMode, modTime time.Time, size int64) error {
|
||||
if mode == 0 {
|
||||
mode = 0o600
|
||||
}
|
||||
header := &tar.Header{
|
||||
Name: name,
|
||||
Mode: int64(mode),
|
||||
Size: size,
|
||||
ModTime: modTime,
|
||||
}
|
||||
if err := writer.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write tar header %q: %w", name, err)
|
||||
}
|
||||
if _, err := io.Copy(writer, file); err != nil {
|
||||
return fmt.Errorf("write tar entry %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
426
pkg/upload/client.go
Normal file
426
pkg/upload/client.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadPath = "upload"
|
||||
runsPath = "runs"
|
||||
idempotencyKeyHeader = "Idempotency-Key"
|
||||
defaultHTTPTimeout = 30 * time.Second
|
||||
defaultRetryAttempts = 3
|
||||
defaultRetryBaseDelay = 100 * time.Millisecond
|
||||
defaultRetryMaxDelay = time.Second
|
||||
uploadContentTypeGzip = "application/gzip"
|
||||
authorizationPrefix = "Bearer "
|
||||
redactedSecret = "[redacted]"
|
||||
)
|
||||
|
||||
func NewClient(opts ClientOptions) (*Client, error) {
|
||||
endpoint, err := cleanEndpoint(opts.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.Token == "" {
|
||||
return nil, fmt.Errorf("token is required")
|
||||
}
|
||||
retry, err := cleanRetryOptions(opts.Retry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := opts.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
return &Client{
|
||||
endpoint: endpoint,
|
||||
token: opts.Token,
|
||||
httpClient: httpClient,
|
||||
retry: retry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadBundle(ctx context.Context, opts UploadBundleOptions) (Result, error) {
|
||||
if c == nil {
|
||||
return Result{}, fmt.Errorf("client is nil")
|
||||
}
|
||||
if opts.Validate && opts.DisableValidation {
|
||||
return Result{}, fmt.Errorf("validate and disable validation cannot both be set")
|
||||
}
|
||||
if opts.Root == "" {
|
||||
return Result{}, fmt.Errorf("root is required")
|
||||
}
|
||||
manifest, err := bundle.LoadManifest(opts.Root)
|
||||
if err != nil {
|
||||
return Result{}, c.redactError(err)
|
||||
}
|
||||
if shouldValidateBundle(opts.Validate, opts.DisableValidation) {
|
||||
if err := bundle.ValidateBundle(opts.Root, manifest); err != nil {
|
||||
return Result{}, c.redactError(err)
|
||||
}
|
||||
}
|
||||
archive, err := archiveBundle(opts.Root, manifest)
|
||||
if err != nil {
|
||||
return Result{}, c.redactError(err)
|
||||
}
|
||||
key, err := uploadIdempotencyKey(opts.IdempotencyKey)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return c.uploadArchive(ctx, archive, key)
|
||||
}
|
||||
|
||||
func (c *Client) UploadFiles(ctx context.Context, opts UploadFilesOptions) (Result, error) {
|
||||
if c == nil {
|
||||
return Result{}, fmt.Errorf("client is nil")
|
||||
}
|
||||
if opts.Validate && opts.DisableValidation {
|
||||
return Result{}, fmt.Errorf("validate and disable validation cannot both be set")
|
||||
}
|
||||
if opts.ID == "" {
|
||||
return Result{}, fmt.Errorf("id is required")
|
||||
}
|
||||
if len(opts.Files) == 0 {
|
||||
return Result{}, fmt.Errorf("files is required")
|
||||
}
|
||||
tempRoot, err := os.MkdirTemp(opts.TempDir, "distributor-upload-*")
|
||||
if err != nil {
|
||||
return Result{}, c.redactError(fmt.Errorf("create temporary bundle root: %w", err))
|
||||
}
|
||||
defer func() {
|
||||
_ = os.RemoveAll(tempRoot)
|
||||
}()
|
||||
localBundleRoot := filepath.Join(tempRoot, "bundle")
|
||||
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
|
||||
Root: localBundleRoot,
|
||||
ID: opts.ID,
|
||||
Created: opts.Created,
|
||||
Files: opts.Files,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, c.redactError(err)
|
||||
}
|
||||
if shouldValidateBundle(opts.Validate, opts.DisableValidation) {
|
||||
if err := bundle.ValidateBundle(localBundleRoot, manifest); err != nil {
|
||||
return Result{}, c.redactError(err)
|
||||
}
|
||||
}
|
||||
archive, err := archiveBundle(localBundleRoot, manifest)
|
||||
if err != nil {
|
||||
return Result{}, c.redactError(err)
|
||||
}
|
||||
key, err := uploadIdempotencyKey(opts.IdempotencyKey)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return c.uploadArchive(ctx, archive, key)
|
||||
}
|
||||
|
||||
func (c *Client) Status(ctx context.Context, runID string) (RunStatus, error) {
|
||||
if c == nil {
|
||||
return RunStatus{}, fmt.Errorf("client is nil")
|
||||
}
|
||||
if runID == "" {
|
||||
return RunStatus{}, fmt.Errorf("run id is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RunStatus{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.statusURL(runID), nil)
|
||||
if err != nil {
|
||||
return RunStatus{}, c.redactError(err)
|
||||
}
|
||||
c.authorize(request)
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return RunStatus{}, c.redactError(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return RunStatus{}, c.responseError(response)
|
||||
}
|
||||
var status RunStatus
|
||||
if err := json.NewDecoder(response.Body).Decode(&status); err != nil {
|
||||
return RunStatus{}, c.redactError(fmt.Errorf("decode run status: %w", err))
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *Client) uploadArchive(ctx context.Context, archive []byte, idempotencyKey string) (Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= c.retry.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result, retry, err := c.uploadAttempt(ctx, archive, idempotencyKey)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !retry || attempt == c.retry.MaxAttempts {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := waitForRetry(ctx, retryDelay(c.retry, attempt)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
return Result{}, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) uploadAttempt(ctx context.Context, archive []byte, idempotencyKey string) (Result, bool, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.uploadURL(), bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return Result{}, false, c.redactError(err)
|
||||
}
|
||||
c.authorize(request)
|
||||
request.Header.Set("Content-Type", uploadContentTypeGzip)
|
||||
request.Header.Set(idempotencyKeyHeader, idempotencyKey)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return Result{}, false, ctxErr
|
||||
}
|
||||
return Result{}, isRetryableNetworkError(err), c.redactError(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode == http.StatusAccepted {
|
||||
var result Result
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return Result{}, false, c.redactError(fmt.Errorf("decode upload response: %w", err))
|
||||
}
|
||||
if result.RunID == "" {
|
||||
return Result{}, false, fmt.Errorf("upload response run_id is required")
|
||||
}
|
||||
return result, false, nil
|
||||
}
|
||||
err = c.responseError(response)
|
||||
return Result{}, response.StatusCode == http.StatusServiceUnavailable, err
|
||||
}
|
||||
|
||||
func (c *Client) authorize(request *http.Request) {
|
||||
request.Header.Set("Authorization", authorizationPrefix+c.token)
|
||||
}
|
||||
|
||||
func (c *Client) uploadURL() string {
|
||||
return joinEndpointPath(c.endpoint, uploadPath)
|
||||
}
|
||||
|
||||
func (c *Client) statusURL(runID string) string {
|
||||
return joinEndpointPath(c.endpoint, runsPath, runID)
|
||||
}
|
||||
|
||||
func (c *Client) responseError(response *http.Response) error {
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
message := http.StatusText(response.StatusCode)
|
||||
retryable := false
|
||||
if readErr == nil && len(body) > 0 {
|
||||
var decoded struct {
|
||||
Error string `json:"error"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err == nil && decoded.Error != "" {
|
||||
message = decoded.Error
|
||||
retryable = decoded.Retryable
|
||||
} else if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
|
||||
message = trimmed
|
||||
}
|
||||
}
|
||||
message = c.redactString(message)
|
||||
status := c.redactString(response.Status)
|
||||
httpErr := HTTPError{
|
||||
StatusCode: response.StatusCode,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Retryable: retryable,
|
||||
}
|
||||
if response.StatusCode == http.StatusConflict {
|
||||
return &IdempotencyConflictError{HTTPError: httpErr}
|
||||
}
|
||||
return &httpErr
|
||||
}
|
||||
|
||||
func (c *Client) redactError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
message := c.redactString(err.Error())
|
||||
if message == err.Error() {
|
||||
return err
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
func (c *Client) redactString(value string) string {
|
||||
if c == nil || c.token == "" {
|
||||
return value
|
||||
}
|
||||
return strings.ReplaceAll(value, c.token, redactedSecret)
|
||||
}
|
||||
|
||||
func cleanEndpoint(value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("endpoint is required")
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("endpoint is invalid: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("endpoint scheme must be http or https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("endpoint host is required")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", fmt.Errorf("endpoint userinfo is not supported")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("endpoint must not include query or fragment")
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawPath = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func cleanRetryOptions(opts RetryOptions) (RetryOptions, error) {
|
||||
if opts.MaxAttempts < 0 {
|
||||
return RetryOptions{}, fmt.Errorf("retry max attempts must be non-negative")
|
||||
}
|
||||
if opts.BaseDelay < 0 {
|
||||
return RetryOptions{}, fmt.Errorf("retry base delay must be non-negative")
|
||||
}
|
||||
if opts.MaxDelay < 0 {
|
||||
return RetryOptions{}, fmt.Errorf("retry max delay must be non-negative")
|
||||
}
|
||||
if opts.MaxAttempts == 0 {
|
||||
opts.MaxAttempts = defaultRetryAttempts
|
||||
}
|
||||
if opts.BaseDelay == 0 {
|
||||
opts.BaseDelay = defaultRetryBaseDelay
|
||||
}
|
||||
if opts.MaxDelay == 0 {
|
||||
opts.MaxDelay = defaultRetryMaxDelay
|
||||
}
|
||||
if opts.MaxDelay < opts.BaseDelay {
|
||||
return RetryOptions{}, fmt.Errorf("retry max delay must be greater than or equal to base delay")
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func uploadIdempotencyKey(value string) (string, error) {
|
||||
if value == "" {
|
||||
return randomIdempotencyKey()
|
||||
}
|
||||
if err := validateIdempotencyKey(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateIdempotencyKey(value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("idempotency key is required")
|
||||
}
|
||||
if len(value) > 128 {
|
||||
return fmt.Errorf("idempotency key must be at most 128 bytes")
|
||||
}
|
||||
for index := 0; index < len(value); index++ {
|
||||
character := value[index]
|
||||
if character >= 'a' && character <= 'z' ||
|
||||
character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' ||
|
||||
character == '.' ||
|
||||
character == '_' ||
|
||||
character == '-' ||
|
||||
character == ':' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("idempotency key contains unsupported character")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomIdempotencyKey() (string, error) {
|
||||
var data [16]byte
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
return "", fmt.Errorf("generate idempotency key: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(data[:]), nil
|
||||
}
|
||||
|
||||
func shouldValidateBundle(validate, disable bool) bool {
|
||||
return validate || !disable
|
||||
}
|
||||
|
||||
func retryDelay(opts RetryOptions, attempt int) time.Duration {
|
||||
delay := opts.BaseDelay
|
||||
for index := 1; index < attempt; index++ {
|
||||
delay *= 2
|
||||
if delay >= opts.MaxDelay {
|
||||
return opts.MaxDelay
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableNetworkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return netErr.Timeout() || netErr.Temporary()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func joinEndpointPath(endpoint string, elements ...string) string {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return endpoint
|
||||
}
|
||||
parts := []string{}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
parts = append(parts, strings.Trim(parsed.Path, "/"))
|
||||
}
|
||||
parts = append(parts, elements...)
|
||||
parsed.Path = "/" + path.Join(parts...)
|
||||
return parsed.String()
|
||||
}
|
||||
559
pkg/upload/client_test.go
Normal file
559
pkg/upload/client_test.go
Normal file
@@ -0,0 +1,559 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sourcebundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
)
|
||||
|
||||
func TestNewClientValidatesOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ClientOptions
|
||||
}{
|
||||
{name: "missing endpoint", opts: ClientOptions{Token: "secret"}},
|
||||
{name: "missing token", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080"}},
|
||||
{name: "bad scheme", opts: ClientOptions{Endpoint: "ftp://127.0.0.1:8080", Token: "secret"}},
|
||||
{name: "missing host", opts: ClientOptions{Endpoint: "http:///upload", Token: "secret"}},
|
||||
{name: "query", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080?x=1", Token: "secret"}},
|
||||
{name: "userinfo", opts: ClientOptions{Endpoint: "http://user@127.0.0.1:8080", Token: "secret"}},
|
||||
{name: "negative attempts", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080", Token: "secret", Retry: RetryOptions{MaxAttempts: -1}}},
|
||||
{name: "negative delay", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080", Token: "secret", Retry: RetryOptions{BaseDelay: -1}}},
|
||||
{name: "max below base", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080", Token: "secret", Retry: RetryOptions{BaseDelay: time.Second, MaxDelay: time.Millisecond}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := NewClient(tt.opts); err == nil {
|
||||
t.Fatal("NewClient() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
client, err := NewClient(ClientOptions{Endpoint: "http://127.0.0.1:8080/base/", Token: "secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if got, want := client.uploadURL(), "http://127.0.0.1:8080/base/upload"; got != want {
|
||||
t.Fatalf("upload URL = %q, want %q", got, want)
|
||||
}
|
||||
if client.httpClient == nil || client.httpClient.Timeout == 0 {
|
||||
t.Fatalf("default HTTP client = %#v, want timeout", client.httpClient)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadBundleSendsCallerKeyAndManifestArchive(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{
|
||||
{path: "report.md", data: "# Report\n"},
|
||||
{path: "nested/summary.txt", data: "Summary\n"},
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(root, "unlisted.txt"), []byte("nope"), 0o600); err != nil {
|
||||
t.Fatalf("write unlisted file: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.URL.Path, "/upload"; got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "Bearer secret-token"; got != want {
|
||||
t.Fatalf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := r.Header.Get("Content-Type"), uploadContentTypeGzip; got != want {
|
||||
t.Fatalf("content type = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := r.Header.Get(idempotencyKeyHeader), "producer.retry:one"; got != want {
|
||||
t.Fatalf("idempotency key = %q, want %q", got, want)
|
||||
}
|
||||
entries := readArchiveEntries(t, r.Body)
|
||||
if got, want := strings.Join(entryNames(entries), ","), "manifest.json,report.md,nested/summary.txt"; got != want {
|
||||
t.Fatalf("archive entries = %q, want %q", got, want)
|
||||
}
|
||||
if _, ok := entries["unlisted.txt"]; ok {
|
||||
t.Fatal("archive included unlisted file")
|
||||
}
|
||||
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret-token", HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
result, err := client.UploadBundle(context.Background(), UploadBundleOptions{
|
||||
Root: root,
|
||||
IdempotencyKey: "producer.retry:one",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadBundle() error = %v", err)
|
||||
}
|
||||
if result.RunID != "reports.20260604T120000Z.abcdef12" || result.Status != "accepted" {
|
||||
t.Fatalf("result = %#v, want accepted run", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFilesBuildsTemporaryBundleWithoutTouchingSources(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
sourcePath := filepath.Join(sourceRoot, "producer-output.md")
|
||||
if err := os.WriteFile(sourcePath, []byte("producer data\n"), 0o600); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
tempDir := t.TempDir()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
entries := readArchiveEntries(t, r.Body)
|
||||
if got := string(entries["manifest.json"]); !strings.Contains(got, `"id": "reports.from.files"`) {
|
||||
t.Fatalf("manifest = %s, want uploaded id", got)
|
||||
}
|
||||
if got, want := string(entries["reports/report.md"]), "producer data\n"; got != want {
|
||||
t.Fatalf("uploaded file = %q, want %q", got, want)
|
||||
}
|
||||
if _, ok := entries["producer-output.md"]; ok {
|
||||
t.Fatal("archive used producer source path instead of bundle path")
|
||||
}
|
||||
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
_, err = client.UploadFiles(context.Background(), UploadFilesOptions{
|
||||
ID: "reports.from.files",
|
||||
Files: []sourcebundle.BundleFile{{
|
||||
SourcePath: sourcePath,
|
||||
Path: "reports/report.md",
|
||||
}},
|
||||
TempDir: tempDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadFiles() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourceRoot, sourcebundle.ManifestName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("producer source manifest stat = %v, want not exist", err)
|
||||
}
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read temp dir: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("temp dir entries = %d, want cleanup", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadBundleValidationFailurePreventsHTTPRequest(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "original"}})
|
||||
if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("changed"), 0o600); err != nil {
|
||||
t.Fatalf("mutate bundle file: %v", err)
|
||||
}
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
t.Fatal("server should not receive request")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root}); err == nil {
|
||||
t.Fatal("UploadBundle() error = nil, want validation error")
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadBundleCanDisableLocalValidation(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "original"}})
|
||||
if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("changed"), 0o600); err != nil {
|
||||
t.Fatalf("mutate bundle file: %v", err)
|
||||
}
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, DisableValidation: true}); err != nil {
|
||||
t.Fatalf("UploadBundle() error = %v", err)
|
||||
}
|
||||
if got := requests.Load(); got != 1 {
|
||||
t.Fatalf("requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedIdempotencyKeyIsReusedAcrossRetry(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
|
||||
var attempts atomic.Int64
|
||||
var keys []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
keys = append(keys, r.Header.Get(idempotencyKeyHeader))
|
||||
if attempts.Add(1) == 1 {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "busy", false)
|
||||
return
|
||||
}
|
||||
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{
|
||||
Endpoint: server.URL,
|
||||
Token: "secret",
|
||||
HTTPClient: server.Client(),
|
||||
Retry: RetryOptions{MaxAttempts: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root}); err != nil {
|
||||
t.Fatalf("UploadBundle() error = %v", err)
|
||||
}
|
||||
if got, want := attempts.Load(), int64(2); got != want {
|
||||
t.Fatalf("attempts = %d, want %d", got, want)
|
||||
}
|
||||
if len(keys) != 2 || keys[0] == "" || keys[0] != keys[1] {
|
||||
t.Fatalf("idempotency keys = %#v, want same generated key", keys)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(keys[0]) {
|
||||
t.Fatalf("generated key = %q, want 128-bit lowercase hex", keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadResponseParsingAndNoRetryStatuses(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantConflict bool
|
||||
wantMessage string
|
||||
wantRetryable bool
|
||||
}{
|
||||
{name: "bad request", status: http.StatusBadRequest, body: `{"error":"bad bundle"}`, wantMessage: "bad bundle"},
|
||||
{name: "unauthorized", status: http.StatusUnauthorized, body: `{"error":"bad token"}`, wantMessage: "bad token"},
|
||||
{name: "conflict", status: http.StatusConflict, body: `{"error":"different manifest","retryable":true}`, wantConflict: true, wantMessage: "different manifest", wantRetryable: true},
|
||||
{name: "too large", status: http.StatusRequestEntityTooLarge, body: `{"error":"too large"}`, wantMessage: "too large"},
|
||||
{name: "unsupported", status: http.StatusUnsupportedMediaType, body: `{"error":"unsupported"}`, wantMessage: "unsupported"},
|
||||
{name: "service unavailable", status: http.StatusServiceUnavailable, body: `{"error":"busy"}`, wantMessage: "busy"},
|
||||
{name: "non json", status: http.StatusBadRequest, body: `plain failure`, wantMessage: "plain failure"},
|
||||
{name: "unexpected", status: http.StatusTeapot, body: ``, wantMessage: "I'm a teapot"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var attempts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{
|
||||
Endpoint: server.URL,
|
||||
Token: "secret",
|
||||
HTTPClient: server.Client(),
|
||||
Retry: RetryOptions{MaxAttempts: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
_, err = client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "key"})
|
||||
if err == nil {
|
||||
t.Fatal("UploadBundle() error = nil, want error")
|
||||
}
|
||||
var httpErr *HTTPError
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("error = %T %v, want HTTPError", err, err)
|
||||
}
|
||||
if httpErr.StatusCode != tt.status || !strings.Contains(httpErr.Message, tt.wantMessage) || httpErr.Retryable != tt.wantRetryable {
|
||||
t.Fatalf("HTTPError = %#v, want status %d message %q retryable %t", httpErr, tt.status, tt.wantMessage, tt.wantRetryable)
|
||||
}
|
||||
var conflict *IdempotencyConflictError
|
||||
if got := errors.As(err, &conflict); got != tt.wantConflict {
|
||||
t.Fatalf("conflict error = %t, want %t", got, tt.wantConflict)
|
||||
}
|
||||
if got := attempts.Load(); got != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRedactedFromHTTPError(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
|
||||
token := "super-secret-token"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONError(w, http.StatusBadRequest, "token "+token+" rejected", false)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: token, HTTPClient: server.Client(), Retry: RetryOptions{MaxAttempts: 1}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
_, err = client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "key"})
|
||||
if err == nil {
|
||||
t.Fatal("UploadBundle() error = nil, want error")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("error exposed token: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), redactedSecret) {
|
||||
t.Fatalf("error = %v, want redaction marker", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkRetryUsesSameIdempotencyKey(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
|
||||
var attempts atomic.Int64
|
||||
var keys []string
|
||||
client, err := NewClient(ClientOptions{
|
||||
Endpoint: "http://upload.example",
|
||||
Token: "secret",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
keys = append(keys, request.Header.Get(idempotencyKeyHeader))
|
||||
if attempts.Add(1) == 1 {
|
||||
return nil, temporaryNetworkError{}
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusAccepted,
|
||||
Status: "202 Accepted",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"run_id":"reports.20260604T120000Z.abcdef12","status":"accepted"}`)),
|
||||
Request: request,
|
||||
}, nil
|
||||
})},
|
||||
Retry: RetryOptions{MaxAttempts: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
result, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "network-retry"})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadBundle() error = %v", err)
|
||||
}
|
||||
if result.RunID == "" {
|
||||
t.Fatalf("result = %#v, want run id", result)
|
||||
}
|
||||
if got, want := attempts.Load(), int64(2); got != want {
|
||||
t.Fatalf("attempts = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := strings.Join(keys, ","), "network-retry,network-retry"; got != want {
|
||||
t.Fatalf("keys = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationDuringRetryBackoff(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var attempts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
cancel()
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "busy", false)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{
|
||||
Endpoint: server.URL,
|
||||
Token: "secret",
|
||||
HTTPClient: server.Client(),
|
||||
Retry: RetryOptions{MaxAttempts: 2, BaseDelay: time.Hour, MaxDelay: time.Hour},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
_, err = client.UploadBundle(ctx, UploadBundleOptions{Root: root, IdempotencyKey: "cancel"})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("UploadBundle() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := attempts.Load(); got != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusParsesRunStatusAndErrors(t *testing.T) {
|
||||
acceptedAt := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.URL.Path, "/runs/reports.20260604T120000Z.abcdef12"; got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "Bearer secret"; got != want {
|
||||
t.Fatalf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(RunStatus{
|
||||
RunID: "reports.20260604T120000Z.abcdef12",
|
||||
PipelineID: "reports",
|
||||
Status: "succeeded",
|
||||
AcceptedAt: acceptedAt,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
status, err := client.Status(context.Background(), "reports.20260604T120000Z.abcdef12")
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
if status.RunID != "reports.20260604T120000Z.abcdef12" || status.PipelineID != "reports" || status.Status != "succeeded" || !status.AcceptedAt.Equal(acceptedAt) {
|
||||
t.Fatalf("status = %#v, want succeeded run", status)
|
||||
}
|
||||
|
||||
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONError(w, http.StatusNotFound, "run not found", false)
|
||||
}))
|
||||
defer errorServer.Close()
|
||||
client, err = NewClient(ClientOptions{Endpoint: errorServer.URL, Token: "secret", HTTPClient: errorServer.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
_, err = client.Status(context.Background(), "missing")
|
||||
var httpErr *HTTPError
|
||||
if err == nil || !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("Status() error = %v, want 404 HTTPError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidCallerIdempotencyKeyPreventsHTTPRequest(t *testing.T) {
|
||||
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "bad key"}); err == nil {
|
||||
t.Fatal("UploadBundle() error = nil, want invalid key error")
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
type testFile struct {
|
||||
path string
|
||||
data string
|
||||
}
|
||||
|
||||
func writeTestBundle(t *testing.T, id string, files []testFile) string {
|
||||
t.Helper()
|
||||
sourceRoot := t.TempDir()
|
||||
bundleFiles := make([]sourcebundle.BundleFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
sourcePath := filepath.Join(sourceRoot, filepath.FromSlash(file.path))
|
||||
if err := os.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir source parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, []byte(file.data), 0o600); err != nil {
|
||||
t.Fatalf("write source file: %v", err)
|
||||
}
|
||||
bundleFiles = append(bundleFiles, sourcebundle.BundleFile{
|
||||
SourcePath: sourcePath,
|
||||
Path: file.path,
|
||||
})
|
||||
}
|
||||
root := filepath.Join(t.TempDir(), "bundle")
|
||||
if _, err := sourcebundle.WriteBundle(sourcebundle.WriteBundleOptions{
|
||||
Root: root,
|
||||
ID: id,
|
||||
Created: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC),
|
||||
Files: bundleFiles,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteBundle() error = %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func readArchiveEntries(t *testing.T, body io.Reader) map[string][]byte {
|
||||
t.Helper()
|
||||
gzipReader, err := gzip.NewReader(body)
|
||||
if err != nil {
|
||||
t.Fatalf("open gzip archive: %v", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
entries := map[string][]byte{}
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return entries
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read tar archive: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
t.Fatalf("read tar entry %q: %v", header.Name, err)
|
||||
}
|
||||
entries[header.Name] = data
|
||||
}
|
||||
}
|
||||
|
||||
func entryNames(entries map[string][]byte) []string {
|
||||
ordered := []string{}
|
||||
for _, name := range []string{"manifest.json", "report.md", "nested/summary.txt", "reports/report.md", "unlisted.txt"} {
|
||||
if _, ok := entries[name]; ok {
|
||||
ordered = append(ordered, name)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func writeAccepted(t *testing.T, w http.ResponseWriter, runID string) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
if err := json.NewEncoder(w).Encode(Result{RunID: runID, Status: "accepted"}); err != nil {
|
||||
t.Fatalf("write accepted response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, message string, retryable bool) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": message, "retryable": retryable})
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
type temporaryNetworkError struct{}
|
||||
|
||||
func (temporaryNetworkError) Error() string {
|
||||
return "temporary network failure"
|
||||
}
|
||||
|
||||
func (temporaryNetworkError) Timeout() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (temporaryNetworkError) Temporary() bool {
|
||||
return true
|
||||
}
|
||||
92
pkg/upload/types.go
Normal file
92
pkg/upload/types.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
endpoint string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
retry RetryOptions
|
||||
}
|
||||
|
||||
type ClientOptions struct {
|
||||
Endpoint string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
Retry RetryOptions
|
||||
}
|
||||
|
||||
type RetryOptions struct {
|
||||
MaxAttempts int
|
||||
BaseDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
}
|
||||
|
||||
type UploadBundleOptions struct {
|
||||
Root string
|
||||
Validate bool
|
||||
DisableValidation bool
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type UploadFilesOptions struct {
|
||||
ID string
|
||||
Created time.Time
|
||||
Files []bundle.BundleFile
|
||||
Validate bool
|
||||
DisableValidation bool
|
||||
TempDir string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
RunID string `json:"run_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type RunStatus struct {
|
||||
RunID string `json:"run_id"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
Status string `json:"status"`
|
||||
AcceptedAt time.Time `json:"accepted_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
Report json.RawMessage `json:"report,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
Status string
|
||||
Message string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (err *HTTPError) Error() string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if err.Message == "" {
|
||||
return fmt.Sprintf("upload request failed: %s", err.Status)
|
||||
}
|
||||
return fmt.Sprintf("upload request failed: %s: %s", err.Status, err.Message)
|
||||
}
|
||||
|
||||
type IdempotencyConflictError struct {
|
||||
HTTPError
|
||||
}
|
||||
|
||||
func (err *IdempotencyConflictError) Error() string {
|
||||
return (*HTTPError)(&err.HTTPError).Error()
|
||||
}
|
||||
|
||||
func (err *IdempotencyConflictError) Unwrap() error {
|
||||
return &err.HTTPError
|
||||
}
|
||||
Reference in New Issue
Block a user