From d637949db436d6c397461922b53ea00aa923f5f3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 4 Jun 2026 14:10:51 +0000 Subject: [PATCH] Add producer upload client package --- pkg/upload/archive.go | 104 +++++++ pkg/upload/client.go | 426 +++++++++++++++++++++++++++++ pkg/upload/client_test.go | 559 ++++++++++++++++++++++++++++++++++++++ pkg/upload/types.go | 92 +++++++ 4 files changed, 1181 insertions(+) create mode 100644 pkg/upload/archive.go create mode 100644 pkg/upload/client.go create mode 100644 pkg/upload/client_test.go create mode 100644 pkg/upload/types.go diff --git a/pkg/upload/archive.go b/pkg/upload/archive.go new file mode 100644 index 0000000..13cc489 --- /dev/null +++ b/pkg/upload/archive.go @@ -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 +} diff --git a/pkg/upload/client.go b/pkg/upload/client.go new file mode 100644 index 0000000..93ce680 --- /dev/null +++ b/pkg/upload/client.go @@ -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() +} diff --git a/pkg/upload/client_test.go b/pkg/upload/client_test.go new file mode 100644 index 0000000..5dd6349 --- /dev/null +++ b/pkg/upload/client_test.go @@ -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 +} diff --git a/pkg/upload/types.go b/pkg/upload/types.go new file mode 100644 index 0000000..db3b89a --- /dev/null +++ b/pkg/upload/types.go @@ -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 "" + } + 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 +}