Files
releasekit/internal/delivery/remote_assets.go

263 lines
8.6 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) (UploadResult, error) {
expected := orderedExpected(inspection.Assets)
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
missing := make(map[string]artifact.ExpectedAsset, len(expected))
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)
missing[observation.Expected.Filename] = observation.Expected
case AssetConflicting:
preflightConflict = true
result.Conflicting = append(result.Conflicting, item)
case AssetUnverifiable:
preflightUnverifiable = true
result.Uncertain = append(result.Uncertain, item)
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.New("one or more existing attachments conflict")}
}
if preflightUnverifiable {
result.State = NotAttempted
return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.New("one or more existing attachments could not be verified")}
}
if len(missing) == 0 {
result.State = Complete
return result, nil
}
for _, item := range orderMissing(expected) {
if _, needsUpload := missing[item.Filename]; !needsUpload {
continue
}
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))
result.Pending = removeIdentity(result.Pending, item.Filename)
continue
}
operationErr := errors.Join(uploadErr, closeErr)
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain {
return result, operationErr
}
if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil {
return result, err
}
}
if len(result.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.Pending = removeIdentity(result.Pending, item.Filename)
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))
result.Pending = removeIdentity(result.Pending, item.Filename)
return nil
case AssetConflicting:
result.State = Conflict
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Conflicting = append(result.Conflicting, identity(item))
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
case AssetMissing:
result.Pending = removeIdentity(result.Pending, item.Filename)
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.Pending = removeIdentity(result.Pending, item.Filename)
result.Uncertain = append(result.Uncertain, identity(item))
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)}
}
}
func orderMissing(expected []artifact.ExpectedAsset) []artifact.ExpectedAsset {
result := append([]artifact.ExpectedAsset(nil), 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 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)
}
return orderMissing(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
}