258 lines
8.5 KiB
Go
258 lines
8.5 KiB
Go
package delivery
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
|
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
|
"go.maximumdirect.net/releasekit/internal/forge"
|
|
)
|
|
|
|
type AssetState string
|
|
|
|
const (
|
|
AssetMatching AssetState = "matching"
|
|
AssetMissing AssetState = "missing"
|
|
AssetConflicting AssetState = "conflicting"
|
|
AssetUnverifiable AssetState = "unverifiable"
|
|
)
|
|
|
|
type AssetIdentity struct {
|
|
Filename string
|
|
SHA256 string
|
|
}
|
|
|
|
type AssetInspection struct {
|
|
Expected artifact.ExpectedAsset
|
|
State AssetState
|
|
Existing []forge.Asset
|
|
Err error
|
|
}
|
|
|
|
type Inspection struct {
|
|
Assets []AssetInspection
|
|
Unrelated []forge.Asset
|
|
}
|
|
|
|
type UploadResult struct {
|
|
State State
|
|
Matching []AssetIdentity
|
|
Uploaded []AssetIdentity
|
|
Pending []AssetIdentity
|
|
Conflicting []AssetIdentity
|
|
Uncertain []AssetIdentity
|
|
}
|
|
|
|
func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHandle any, expected []artifact.ExpectedAsset) (Inspection, error) {
|
|
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
|
return Inspection{}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
|
}
|
|
listed, err := backend.ListAssets(ctx, releaseHandle)
|
|
if err != nil {
|
|
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
|
for _, item := range expected {
|
|
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: item, State: AssetUnverifiable, Err: err})
|
|
}
|
|
return inspection, err
|
|
}
|
|
|
|
byName := make(map[string][]forge.Asset, len(listed))
|
|
for _, item := range listed {
|
|
byName[item.Filename] = append(byName[item.Filename], item)
|
|
}
|
|
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
|
declared := make(map[string]struct{}, len(expected))
|
|
for _, item := range expected {
|
|
declared[item.Filename] = struct{}{}
|
|
matches := byName[item.Filename]
|
|
observation := AssetInspection{Expected: item, Existing: matches}
|
|
switch len(matches) {
|
|
case 0:
|
|
observation.State = AssetMissing
|
|
case 1:
|
|
digest, err := remoteAssetDigest(ctx, backend, matches[0].Handle)
|
|
if err != nil {
|
|
observation.State = AssetUnverifiable
|
|
observation.Err = err
|
|
} else if digest == item.SHA256 {
|
|
observation.State = AssetMatching
|
|
} else {
|
|
observation.State = AssetConflicting
|
|
}
|
|
default:
|
|
observation.State = AssetConflicting
|
|
}
|
|
inspection.Assets = append(inspection.Assets, observation)
|
|
}
|
|
for _, item := range listed {
|
|
if _, ok := declared[item.Filename]; !ok {
|
|
inspection.Unrelated = append(inspection.Unrelated, item)
|
|
}
|
|
}
|
|
return inspection, nil
|
|
}
|
|
|
|
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any) (string, error) {
|
|
stream, err := backend.OpenAsset(ctx, handle)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
hasher := sha256.New()
|
|
_, readErr := io.Copy(hasher, stream)
|
|
closeErr := stream.Close()
|
|
if err := errors.Join(readErr, closeErr); err != nil {
|
|
return "", &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
|
|
}
|
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
|
}
|
|
|
|
func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (result UploadResult, err error) {
|
|
expected := make([]artifact.ExpectedAsset, len(inspection.Assets))
|
|
for index, observation := range inspection.Assets {
|
|
expected[index] = observation.Expected
|
|
}
|
|
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
|
return UploadResult{State: Partial}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
|
}
|
|
result = UploadResult{State: Partial}
|
|
preflightConflict := false
|
|
preflightUnverifiable := false
|
|
var inspectionErrors []error
|
|
var missing []artifact.ExpectedAsset
|
|
pending := make(map[string]struct{}, len(expected))
|
|
// Keep the initial observation order and compact pending identities once,
|
|
// including on early returns after partial or unresolved uploads.
|
|
defer func() {
|
|
remaining := result.Pending[:0]
|
|
for _, item := range result.Pending {
|
|
if _, ok := pending[item.Filename]; ok {
|
|
remaining = append(remaining, item)
|
|
}
|
|
}
|
|
result.Pending = remaining
|
|
}()
|
|
for _, observation := range inspection.Assets {
|
|
item := identity(observation.Expected)
|
|
switch observation.State {
|
|
case AssetMatching:
|
|
result.Matching = append(result.Matching, item)
|
|
case AssetMissing:
|
|
result.Pending = append(result.Pending, item)
|
|
pending[observation.Expected.Filename] = struct{}{}
|
|
missing = append(missing, observation.Expected)
|
|
case AssetConflicting:
|
|
preflightConflict = true
|
|
result.Conflicting = append(result.Conflicting, item)
|
|
case AssetUnverifiable:
|
|
preflightUnverifiable = true
|
|
result.Uncertain = append(result.Uncertain, item)
|
|
if observation.Err != nil {
|
|
inspectionErrors = append(inspectionErrors, fmt.Errorf("verify existing attachment %q: %w", item.Filename, observation.Err))
|
|
}
|
|
default:
|
|
return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename)}
|
|
}
|
|
}
|
|
if preflightConflict {
|
|
result.State = Conflict
|
|
return result, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(errors.New("one or more existing attachments conflict"), errors.Join(inspectionErrors...))}
|
|
}
|
|
if preflightUnverifiable {
|
|
result.State = NotAttempted
|
|
return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.Join(errors.New("one or more existing attachments could not be verified"), errors.Join(inspectionErrors...))}
|
|
}
|
|
if len(pending) == 0 {
|
|
result.State = Complete
|
|
return result, nil
|
|
}
|
|
|
|
orderMissing(missing)
|
|
for _, item := range missing {
|
|
content, err := assetContent(item)
|
|
if err != nil {
|
|
return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("open local release asset %q: %w", item.Filename, err)}
|
|
}
|
|
evidence, uploadErr := backend.UploadAsset(ctx, releaseHandle, item.Filename, content)
|
|
closeErr := content.Close()
|
|
if uploadErr == nil {
|
|
result.Uploaded = append(result.Uploaded, identity(item))
|
|
delete(pending, item.Filename)
|
|
continue
|
|
}
|
|
operationErr := errors.Join(uploadErr, closeErr)
|
|
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain {
|
|
return result, operationErr
|
|
}
|
|
delete(pending, item.Filename)
|
|
if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
if len(pending) == 0 {
|
|
result.State = Complete
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHandle any, item artifact.ExpectedAsset, evidence forge.WriteEvidence, uploadErr error, result *UploadResult) error {
|
|
inspection, inspectErr := inspectRemoteAssets(ctx, backend, releaseHandle, []artifact.ExpectedAsset{item})
|
|
if inspectErr != nil || len(inspection.Assets) != 1 {
|
|
result.State = Unknown
|
|
result.Uncertain = append(result.Uncertain, identity(item))
|
|
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, inspectErr)}
|
|
}
|
|
observation := inspection.Assets[0]
|
|
switch observation.State {
|
|
case AssetMatching:
|
|
result.Matching = append(result.Matching, identity(item))
|
|
return nil
|
|
case AssetConflicting:
|
|
result.State = Conflict
|
|
result.Conflicting = append(result.Conflicting, identity(item))
|
|
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
|
|
case AssetMissing:
|
|
if evidence.Disposition == forge.WriteConflict {
|
|
result.State = Conflict
|
|
result.Conflicting = append(result.Conflicting, identity(item))
|
|
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
|
|
}
|
|
result.State = Unknown
|
|
result.Uncertain = append(result.Uncertain, identity(item))
|
|
return &forge.Failure{Kind: forge.FailureUnknown, Err: uploadErr}
|
|
default:
|
|
result.State = Unknown
|
|
result.Uncertain = append(result.Uncertain, identity(item))
|
|
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)}
|
|
}
|
|
}
|
|
|
|
func orderMissing(missing []artifact.ExpectedAsset) {
|
|
sort.Slice(missing, func(i, j int) bool {
|
|
if missing[i].Filename == artifact.ManifestFilename {
|
|
return false
|
|
}
|
|
if missing[j].Filename == artifact.ManifestFilename {
|
|
return true
|
|
}
|
|
return missing[i].Filename < missing[j].Filename
|
|
})
|
|
}
|
|
|
|
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 identity(asset artifact.ExpectedAsset) AssetIdentity {
|
|
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
|
}
|