Upload missing Gitea release assets
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# Gitea release pages
|
# Gitea release pages
|
||||||
|
|
||||||
ReleaseKit's internal Gitea client verifies a hosted source tag and finds or
|
ReleaseKit's internal Gitea client verifies a hosted source tag, finds or
|
||||||
creates its stable release page. The public delivery facade and CLI workflow
|
creates its stable release page, inspects existing assets, and uploads missing
|
||||||
are planned separately; this integration guide describes the implemented
|
assets. The public delivery facade and CLI workflow are planned separately;
|
||||||
internal operation.
|
this guide describes the implemented internal operations.
|
||||||
|
|
||||||
The caller supplies the Gitea base URL, owner, repository name, and token
|
The caller supplies the Gitea base URL, owner, repository name, and token
|
||||||
explicitly. The client does not read ambient credentials. The token is sent in
|
explicitly. The client does not read ambient credentials. The token is sent in
|
||||||
@@ -43,8 +43,20 @@ not missing. Unrelated attachments are returned separately and are preserved.
|
|||||||
|
|
||||||
Attachment downloads and same-origin redirects carry authorization only when
|
Attachment downloads and same-origin redirects carry authorization only when
|
||||||
their scheme, host, and port match the configured Gitea service. Cross-origin
|
their scheme, host, and port match the configured Gitea service. Cross-origin
|
||||||
URLs and redirects are left unrequested and reported as unverifiable. Inspection
|
URLs and redirects are left unrequested and reported as unverifiable.
|
||||||
uses GET requests only; uploads are a later delivery operation.
|
|
||||||
|
The upload operation accepts only an inspection result without conflicts or
|
||||||
|
unverifiable assets. It uploads missing binaries in filename order and then the
|
||||||
|
missing `SHA256SUMS`, using multipart field `attachment` and the filename query
|
||||||
|
parameter `name`. Matching attachments are retained; unrelated and existing
|
||||||
|
attachments are never overwritten or deleted. Results keep matching, uploaded,
|
||||||
|
pending, conflicting, and uncertain identities separate. After an ambiguous
|
||||||
|
upload or an upload name conflict, the client performs one list and content
|
||||||
|
check. It does not repeat the POST when the result is missing or cannot be
|
||||||
|
verified, so a caller can retry with a new inspection.
|
||||||
|
|
||||||
|
Uploads use the configured service origin and the same explicit token header.
|
||||||
|
Source-only page delivery supplies no binary assets and creates no manifest.
|
||||||
|
|
||||||
The integration tests use a local HTTP fixture and assert request paths,
|
The integration tests use a local HTTP fixture and assert request paths,
|
||||||
payloads, authorization, streaming comparisons, manifest handling, and failure
|
payloads, authorization, streaming comparisons, manifest handling, and failure
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ Implemented internal packages:
|
|||||||
overlays.
|
overlays.
|
||||||
- `internal/build` compiles explicit Go targets with shared version injection,
|
- `internal/build` compiles explicit Go targets with shared version injection,
|
||||||
verifies native version output, and returns generated file digests.
|
verifies native version output, and returns generated file digests.
|
||||||
- `internal/gitea` verifies hosted source tags, finds or creates matching stable
|
- `internal/gitea` verifies hosted source tags, manages matching stable release
|
||||||
release pages, and inspects existing attachments through the Gitea REST API.
|
pages, and inspects and uploads declared attachments through the Gitea REST API.
|
||||||
- `internal/candidate` captures and validates one clean local candidate, checks
|
- `internal/candidate` captures and validates one clean local candidate, checks
|
||||||
its committed release note and local tag state, runs validation once, and
|
its committed release note and local tag state, runs validation once, and
|
||||||
rechecks mutable repository state.
|
rechecks mutable repository state.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReleaseKit staged implementation plan
|
# ReleaseKit staged implementation plan
|
||||||
|
|
||||||
Status: In progress. Stages 1–13 are complete; later stages remain pending.
|
Status: In progress. Stages 1–14 are complete; later stages remain pending.
|
||||||
|
|
||||||
Date: 2026-10-05
|
Date: 2026-10-05
|
||||||
|
|
||||||
@@ -775,7 +775,7 @@ by inspection tests.
|
|||||||
|
|
||||||
## Stage 14: Upload missing assets and report partial delivery
|
## Stage 14: Upload missing assets and report partial delivery
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 13.
|
Status: Complete. Depends on Stage 13.
|
||||||
|
|
||||||
1. Add multipart uploads for missing binaries in filename order, followed by a
|
1. Add multipart uploads for missing binaries in filename order, followed by a
|
||||||
missing manifest. Record completed work and pending asset identities.
|
missing manifest. Record completed work and pending asset identities.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
|
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
|
||||||
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
|
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
|
||||||
current implemented boundary; the internal Gitea page and asset-inspection
|
current implemented boundary; the internal Gitea page and asset-delivery
|
||||||
client is implemented, while asset delivery, facade/CLI integration, and
|
client is implemented, while facade/CLI integration and broader integrations
|
||||||
broader integrations remain planned. The public facade architecture is
|
remain planned. The public facade architecture is
|
||||||
accepted in [ADR 0001](../adr/0001-public-facade.md) and is implemented for
|
accepted in [ADR 0001](../adr/0001-public-facade.md) and is implemented for
|
||||||
runtime version, candidate validation, and source publication.
|
runtime version, candidate validation, and source publication.
|
||||||
The [implementation plan](implementation.md) owns ordered stages, execution
|
The [implementation plan](implementation.md) owns ordered stages, execution
|
||||||
@@ -56,9 +56,9 @@ cleanup.
|
|||||||
The [architecture policy](../policy/architecture.md) owns the adopted
|
The [architecture policy](../policy/architecture.md) owns the adopted
|
||||||
responsibility and dependency rules, including the runtime version boundary.
|
responsibility and dependency rules, including the runtime version boundary.
|
||||||
The root `releasekit` package provides the public facade for candidate
|
The root `releasekit` package provides the public facade for candidate
|
||||||
validation, source publication, and selected-target builds. The internal
|
validation, source publication, and selected-target builds. Internal Gitea
|
||||||
Gitea page and asset-inspection client is implemented; delivery orchestration
|
page and asset-delivery operations are implemented; facade and CLI orchestration
|
||||||
remains planned. The separate public `buildinfo` package provides lightweight
|
remain planned. The separate public `buildinfo` package provides lightweight
|
||||||
runtime version access.
|
runtime version access.
|
||||||
|
|
||||||
Each facade owns its public named types and documented contracts. Public
|
Each facade owns its public named types and documented contracts. Public
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
|||||||
if postErr == nil {
|
if postErr == nil {
|
||||||
return acceptCreated(created, request)
|
return acceptCreated(created, request)
|
||||||
}
|
}
|
||||||
if !shouldReconcileCreate(postStatus, postErr) {
|
if !shouldReconcileWrite(postStatus, postErr) {
|
||||||
return Result{}, postErr
|
return Result{}, postErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
|||||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldReconcileCreate(status int, err error) bool {
|
func shouldReconcileWrite(status int, err error) bool {
|
||||||
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
303
internal/gitea/upload.go
Normal file
303
internal/gitea/upload.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeliveryState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeliveryComplete DeliveryState = "complete"
|
||||||
|
DeliveryPartial DeliveryState = "partial"
|
||||||
|
DeliveryConflict DeliveryState = "conflict"
|
||||||
|
DeliveryUnknown DeliveryState = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AssetIdentity struct {
|
||||||
|
Filename string
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadResult struct {
|
||||||
|
State DeliveryState
|
||||||
|
Matching []AssetIdentity
|
||||||
|
Uploaded []AssetIdentity
|
||||||
|
Pending []AssetIdentity
|
||||||
|
Conflicting []AssetIdentity
|
||||||
|
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) {
|
||||||
|
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 := validateExpectedAssets(expected); err != nil {
|
||||||
|
return UploadResult{State: DeliveryPartial}, 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 = DeliveryUnknown
|
||||||
|
return result, failure(FailureUnknown, "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]ExpectedAsset, len(result.Pending))
|
||||||
|
for _, observation := range inspection.Assets {
|
||||||
|
if observation.State == AssetMissing {
|
||||||
|
missing[observation.Expected.Filename] = observation.Expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range expected {
|
||||||
|
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := verifyLocalAsset(item); err != nil {
|
||||||
|
return result, 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
|
||||||
|
}
|
||||||
|
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item)
|
||||||
|
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, []ExpectedAsset{item})
|
||||||
|
if inspectErr != nil || len(inspection.Assets) != 1 {
|
||||||
|
result.State = DeliveryUnknown
|
||||||
|
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.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.Conflicting = append(result.Conflicting, identity(item))
|
||||||
|
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||||
|
}
|
||||||
|
result.State = DeliveryUnknown
|
||||||
|
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, asset ExpectedAsset) (int, error) {
|
||||||
|
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||||
|
parsed, err := url.Parse(uploadURL)
|
||||||
|
if err != nil {
|
||||||
|
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("name", asset.Filename)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
multipartWriter := multipart.NewWriter(writer)
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename)
|
||||||
|
if partErr == nil {
|
||||||
|
var source io.Reader
|
||||||
|
if len(asset.Content) > 0 {
|
||||||
|
source = bytes.NewReader(asset.Content)
|
||||||
|
} else {
|
||||||
|
file, openErr := os.Open(asset.Path)
|
||||||
|
if openErr != nil {
|
||||||
|
partErr = openErr
|
||||||
|
} else {
|
||||||
|
defer file.Close()
|
||||||
|
source = file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if partErr == nil {
|
||||||
|
_, partErr = io.Copy(part, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeErr := multipartWriter.Close()
|
||||||
|
if partErr == nil {
|
||||||
|
partErr = closeErr
|
||||||
|
}
|
||||||
|
_ = writer.CloseWithError(partErr)
|
||||||
|
writeDone <- partErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), reader)
|
||||||
|
if err != nil {
|
||||||
|
_ = reader.CloseWithError(err)
|
||||||
|
<-writeDone
|
||||||
|
return 0, failure(FailureInvalid, "upload release asset", 0, err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "token "+token)
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||||
|
response, requestErr := client.Do(request)
|
||||||
|
if requestErr != nil {
|
||||||
|
_ = reader.CloseWithError(requestErr)
|
||||||
|
<-writeDone
|
||||||
|
return 0, failure(FailureTransport, "upload release asset", 0, requestErr)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
writeErr := <-writeDone
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return response.StatusCode, responseFailure("upload release asset", response.StatusCode)
|
||||||
|
}
|
||||||
|
var uploaded assetResponse
|
||||||
|
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
||||||
|
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
||||||
|
}
|
||||||
|
if uploaded.ID <= 0 || uploaded.Name != asset.Filename {
|
||||||
|
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
return response.StatusCode, failure(FailureMalformed, "stream uploaded asset", response.StatusCode, writeErr)
|
||||||
|
}
|
||||||
|
return response.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyLocalAsset(asset ExpectedAsset) error {
|
||||||
|
if len(asset.Content) > 0 {
|
||||||
|
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
||||||
|
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
||||||
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("local path for %q does not match its filename", asset.Filename))
|
||||||
|
}
|
||||||
|
file, err := os.Open(asset.Path)
|
||||||
|
if err != nil {
|
||||||
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("open %q: %w", asset.Filename, err))
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("stat %q: %w", asset.Filename, err))
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
||||||
|
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local file does not match declared size for %q", asset.Filename))
|
||||||
|
}
|
||||||
|
hasher := sha256.New()
|
||||||
|
size, err := io.Copy(hasher, file)
|
||||||
|
if err != nil {
|
||||||
|
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("read %q: %w", asset.Filename, err))
|
||||||
|
}
|
||||||
|
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
||||||
|
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedExpected(observations []AssetInspection) []ExpectedAsset {
|
||||||
|
result := make([]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 == ManifestFilename {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if result[j].Filename == ManifestFilename {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return result[i].Filename < result[j].Filename
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func identity(asset 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
|
||||||
|
}
|
||||||
323
internal/gitea/upload_test.go
Normal file
323
internal/gitea/upload_test.go
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
||||||
|
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
||||||
|
server := httptest.NewServer(fixture)
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(server)
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
|
||||||
|
manifest, err := BuildManifest(assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assets = append(assets, manifest)
|
||||||
|
|
||||||
|
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||||
|
if err == nil || first.State != DeliveryPartial {
|
||||||
|
t.Fatalf("first upload result = %#v, error = %v", first, err)
|
||||||
|
}
|
||||||
|
if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("uploaded = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("pending = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection)
|
||||||
|
if err != nil || second.State != DeliveryComplete {
|
||||||
|
t.Fatalf("second upload result = %#v, error = %v", second, err)
|
||||||
|
}
|
||||||
|
if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("second uploaded = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("POST order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) {
|
||||||
|
fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"}
|
||||||
|
server := httptest.NewServer(fixture)
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(server)
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
assets := localAssets(t, []string{"app.bin"})
|
||||||
|
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||||
|
if err != nil || result.State != DeliveryComplete {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("matching = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 {
|
||||||
|
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
||||||
|
assets := localAssets(t, []string{"app.bin"})
|
||||||
|
manifest, err := BuildManifest(assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: ManifestFilename}
|
||||||
|
// The local binary is represented by its file path; the server starts with
|
||||||
|
// that exact content so only the checksum manifest needs uploading.
|
||||||
|
fixture.files["app.bin"] = readExpectedFile(t, assets[0])
|
||||||
|
server := httptest.NewServer(fixture)
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(server)
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
expected := append(assets, manifest)
|
||||||
|
initial, err := client.InspectAssets(context.Background(), destination, 10, expected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||||
|
if err != nil || result.State != DeliveryComplete {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if got, want := names(result.Matching), []string{"app.bin", ManifestFilename}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("matching = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) {
|
||||||
|
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
|
||||||
|
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1}
|
||||||
|
server := httptest.NewServer(fixture)
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(server)
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
assets := localAssets(t, []string{"app.bin"})
|
||||||
|
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||||
|
if err == nil || result.State != DeliveryUnknown {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if got, want := names(result.Pending), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("pending = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) {
|
||||||
|
t.Fatalf("POST names = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) {
|
||||||
|
fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}}
|
||||||
|
server := httptest.NewServer(fixture)
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(server)
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
assets := localAssets(t, []string{"app.bin", "other.bin"})
|
||||||
|
inspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := client.UploadMissing(context.Background(), destination, 10, inspection)
|
||||||
|
if err == nil || result.State != DeliveryConflict {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if len(fixture.postNames()) != 0 {
|
||||||
|
t.Fatalf("uploads happened before conflict review: %v", fixture.postNames())
|
||||||
|
}
|
||||||
|
if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("pending = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadFixture struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
files map[string][]byte
|
||||||
|
posts []string
|
||||||
|
failName string
|
||||||
|
conflictName string
|
||||||
|
dropAckName string
|
||||||
|
failListAfter int
|
||||||
|
listCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "token "+testToken {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == assetListPath {
|
||||||
|
fixture.mu.Lock()
|
||||||
|
fixture.listCount++
|
||||||
|
if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter {
|
||||||
|
fixture.mu.Unlock()
|
||||||
|
http.Error(w, "read failed", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assets := make([]assetResponse, 0, len(fixture.files))
|
||||||
|
for name, content := range fixture.files {
|
||||||
|
assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)})
|
||||||
|
}
|
||||||
|
fixture.mu.Unlock()
|
||||||
|
_ = json.NewEncoder(w).Encode(assets)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") {
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||||
|
fixture.mu.Lock()
|
||||||
|
content, ok := fixture.files[name]
|
||||||
|
fixture.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == assetListPath {
|
||||||
|
fixture.receiveUpload(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
|
||||||
|
http.Error(w, "multipart required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("attachment")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "missing attachment field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read multipart file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if header.Filename != r.URL.Query().Get("name") {
|
||||||
|
http.Error(w, "filename query mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := header.Filename
|
||||||
|
fixture.mu.Lock()
|
||||||
|
fixture.posts = append(fixture.posts, name)
|
||||||
|
attempts := 0
|
||||||
|
for _, posted := range fixture.posts {
|
||||||
|
if posted == name {
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == fixture.failName && attempts == 1 {
|
||||||
|
fixture.mu.Unlock()
|
||||||
|
http.Error(w, "write failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fixture.files[name] = append([]byte(nil), content...)
|
||||||
|
fixture.mu.Unlock()
|
||||||
|
if name == fixture.conflictName {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if name == fixture.dropAckName {
|
||||||
|
hijacker, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection, _, err := hijacker.Hijack()
|
||||||
|
if err == nil {
|
||||||
|
_ = connection.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))})
|
||||||
|
}
|
||||||
|
|
||||||
|
func localAssets(t *testing.T, names []string) []ExpectedAsset {
|
||||||
|
t.Helper()
|
||||||
|
output := t.TempDir()
|
||||||
|
assets := make([]ExpectedAsset, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
content := []byte("binary-" + name)
|
||||||
|
path := filepath.Join(output, name)
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := assetFor(name, content)
|
||||||
|
sum.Content = nil
|
||||||
|
sum.Path = path
|
||||||
|
assets = append(assets, sum)
|
||||||
|
}
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
|
func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte {
|
||||||
|
t.Helper()
|
||||||
|
content, err := os.ReadFile(asset.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixture *uploadFixture) postNames() []string {
|
||||||
|
fixture.mu.Lock()
|
||||||
|
defer fixture.mu.Unlock()
|
||||||
|
return append([]string(nil), fixture.posts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(identities []AssetIdentity) []string {
|
||||||
|
result := make([]string, 0, len(identities))
|
||||||
|
for _, identity := range identities {
|
||||||
|
result = append(result, identity.Filename)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlPathEscape(value string) string {
|
||||||
|
return (&url.URL{Path: value}).EscapedPath()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user