Complete forge delivery regression coverage
This commit is contained in:
@@ -1,208 +1,15 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
type DeliveryState string
|
||||
|
||||
const (
|
||||
DeliveryNotAttempted DeliveryState = "not_attempted"
|
||||
DeliveryComplete DeliveryState = "complete"
|
||||
DeliveryPartial DeliveryState = "partial"
|
||||
DeliveryConflict DeliveryState = "conflict"
|
||||
DeliveryUnknown DeliveryState = "unknown"
|
||||
)
|
||||
|
||||
type AssetIdentity struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// UploadResult reports the current asset observations from one upload attempt.
|
||||
// Each identity appears in at most one category; early failures can leave some
|
||||
// declared assets unreported.
|
||||
type UploadResult struct {
|
||||
State DeliveryState
|
||||
// Matching contains existing attachments with the declared content.
|
||||
Matching []AssetIdentity
|
||||
// Uploaded contains assets whose upload was confirmed during this invocation.
|
||||
Uploaded []AssetIdentity
|
||||
// Pending contains assets known to be missing.
|
||||
Pending []AssetIdentity
|
||||
// Conflicting contains assets with detected content conflicts.
|
||||
Conflicting []AssetIdentity
|
||||
// Uncertain contains assets whose remote state could not be resolved.
|
||||
Uncertain []AssetIdentity
|
||||
}
|
||||
|
||||
// UploadMissing uploads assets classified missing by InspectAssets. It stops
|
||||
// before the first write if any declared existing asset conflicts or cannot be
|
||||
// verified, and never replaces an existing attachment.
|
||||
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
||||
}
|
||||
|
||||
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
||||
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
||||
// inspection, conflict handling, and upload reconciliation.
|
||||
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
||||
}
|
||||
|
||||
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
||||
if releaseID <= 0 {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
expected := orderedExpected(inspection.Assets)
|
||||
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "preflight release assets", 0, err)
|
||||
}
|
||||
result := UploadResult{State: DeliveryPartial}
|
||||
preflightConflict := false
|
||||
preflightUnknown := false
|
||||
for _, observation := range inspection.Assets {
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(observation.Expected))
|
||||
case AssetMissing:
|
||||
result.Pending = append(result.Pending, identity(observation.Expected))
|
||||
case AssetConflicting:
|
||||
preflightConflict = true
|
||||
result.Conflicting = append(result.Conflicting, identity(observation.Expected))
|
||||
case AssetUnverifiable:
|
||||
preflightUnknown = true
|
||||
result.Uncertain = append(result.Uncertain, identity(observation.Expected))
|
||||
default:
|
||||
return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename))
|
||||
}
|
||||
}
|
||||
if preflightConflict {
|
||||
result.State = DeliveryConflict
|
||||
return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict"))
|
||||
}
|
||||
if preflightUnknown {
|
||||
result.State = DeliveryNotAttempted
|
||||
return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified"))
|
||||
}
|
||||
if len(expected) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
|
||||
missing := make(map[string]artifact.ExpectedAsset, len(result.Pending))
|
||||
for _, observation := range inspection.Assets {
|
||||
if observation.State == AssetMissing {
|
||||
missing[observation.Expected.Filename] = observation.Expected
|
||||
}
|
||||
}
|
||||
if verifyLocal {
|
||||
missingAssets := make([]artifact.ExpectedAsset, 0, len(result.Pending))
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
missingAssets = append(missingAssets, item)
|
||||
}
|
||||
if err := artifact.VerifyLocalAssets(missingAssets); err != nil {
|
||||
kind := FailureInvalid
|
||||
if errors.Is(err, artifact.ErrAssetConflict) {
|
||||
kind = FailureConflict
|
||||
}
|
||||
return result, failure(kind, "verify local assets", 0, err)
|
||||
}
|
||||
}
|
||||
|
||||
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
client := c.httpClient()
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
content, openErr := assetContent(item)
|
||||
if openErr != nil {
|
||||
return result, failure(FailureInvalid, "open local release asset", 0, openErr)
|
||||
}
|
||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item.Filename, content)
|
||||
_ = content.Close()
|
||||
if uploadErr == nil {
|
||||
result.Uploaded = append(result.Uploaded, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
continue
|
||||
}
|
||||
if !shouldReconcileWrite(status, uploadErr) {
|
||||
return result, uploadErr
|
||||
}
|
||||
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []artifact.ExpectedAsset{item})
|
||||
if inspectErr != nil || len(inspection.Assets) != 1 {
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
|
||||
}
|
||||
observation := inspection.Assets[0]
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
case AssetConflicting:
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
case AssetMissing:
|
||||
if status == http.StatusConflict {
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
}
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
|
||||
default:
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err))
|
||||
}
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
} else {
|
||||
result.State = DeliveryPartial
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) {
|
||||
if content == nil {
|
||||
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
||||
@@ -263,40 +70,3 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) {
|
||||
if len(asset.Content) > 0 {
|
||||
return io.NopCloser(bytes.NewReader(asset.Content)), nil
|
||||
}
|
||||
return os.Open(asset.Path)
|
||||
}
|
||||
|
||||
func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset {
|
||||
result := make([]artifact.ExpectedAsset, 0, len(observations))
|
||||
for _, observation := range observations {
|
||||
result = append(result, observation.Expected)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Filename == artifact.ManifestFilename {
|
||||
return false
|
||||
}
|
||||
if result[j].Filename == artifact.ManifestFilename {
|
||||
return true
|
||||
}
|
||||
return result[i].Filename < result[j].Filename
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func identity(asset artifact.ExpectedAsset) AssetIdentity {
|
||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
||||
}
|
||||
|
||||
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
|
||||
for index, item := range identities {
|
||||
if item.Filename == filename {
|
||||
return append(identities[:index], identities[index+1:]...)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user