Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/internal/features/update/delta_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestDeltaJobGenerationAndStateTransitionsIntegration(t *testing.T) {
storageRoot := t.TempDir()

noPredecessor := seedSingleRelease(t, tx)
svc := newDeltaIntegrationService(q, storageRoot)
svc := newDeltaIntegrationService(q, tx, storageRoot)
jobs, err := svc.GenerateDeltaJobs(t.Context(), noPredecessor)
if err != nil {
t.Fatalf("generate without predecessor: %v", err)
Expand Down Expand Up @@ -135,7 +135,7 @@ func TestDeltaJobGenerationAndStateTransitionsIntegration(t *testing.T) {
func TestDeltaWorkerAndAdminRoutesIntegration(t *testing.T) {
tx, q := openDeltaIntegrationTx(t)
storageRoot := t.TempDir()
svc := newDeltaIntegrationService(q, storageRoot)
svc := newDeltaIntegrationService(q, tx, storageRoot)
fixture := seedDeltaFixture(t, tx, svc, storageRoot)
handler := NewHandler(svc, nil)

Expand Down Expand Up @@ -233,8 +233,8 @@ func openDeltaIntegrationTx(t *testing.T) (pgx.Tx, *db.Queries) {
return tx, db.New(tx)
}

func newDeltaIntegrationService(q *db.Queries, storageRoot string) *Service {
return NewService(nil, nil, NewRepository(q, nil), nil, "http://clave.test", storageRoot)
func newDeltaIntegrationService(q *db.Queries, tx pgx.Tx, storageRoot string) *Service {
return NewService(nil, nil, NewRepository(q, tx), nil, "http://clave.test", storageRoot)
}

func seedSingleRelease(t *testing.T, tx pgx.Tx) uuid.UUID {
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/features/update/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ type CreateReleaseRequest struct {
ChangelogID string `json:"changelogId,omitempty"`
}

// ReuseArtifactsRequest links the target draft to the payloads of another
// release. It creates new artifact records, but they share the existing stored
// object rather than uploading or copying the binary again.
type ReuseArtifactsRequest struct {
SourceReleaseID string `json:"sourceReleaseId" validate:"required"`
}

type ChangelogDTO struct {
ID string `json:"id"`
ProductID string `json:"productId"`
Expand Down
40 changes: 40 additions & 0 deletions backend/internal/features/update/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,46 @@ func (h *Handler) AdminUploadArtifact(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusCreated, artifact)
}

func (h *Handler) AdminReuseArtifacts(w http.ResponseWriter, r *http.Request) {
orgID, ok := middleware.AdminOrganizationIDFromContext(r.Context())
if !ok {
helpers.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}

targetReleaseID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release id"})
return
}
if err := h.verifyReleaseOwnership(r.Context(), orgID, targetReleaseID); err != nil {
helpers.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "release not found"})
return
}

var req ReuseArtifactsRequest
if !helpers.DecodeValidated(w, r, &req) {
return
}
sourceReleaseID, err := uuid.Parse(req.SourceReleaseID)
if err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source release id"})
return
}
if err := h.verifyReleaseOwnership(r.Context(), orgID, sourceReleaseID); err != nil {
helpers.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "source release not found"})
return
}

artifacts, err := h.svc.ReuseArtifacts(r.Context(), targetReleaseID, sourceReleaseID)
if err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.audit(r, "release.artifacts_reused", "release", &targetReleaseID)
helpers.WriteJSON(w, http.StatusCreated, artifacts)
}

func (h *Handler) AdminPublishRelease(w http.ResponseWriter, r *http.Request) {
orgID, ok := middleware.AdminOrganizationIDFromContext(r.Context())
if !ok {
Expand Down
103 changes: 84 additions & 19 deletions backend/internal/features/update/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ package update
import (
"context"
"encoding/json"
"time"
"errors"

"github.com/cheetahbyte/clave/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)

type transactionBeginner interface {
Begin(context.Context) (pgx.Tx, error)
}

type Repository struct {
q *db.Queries
pool *pgxpool.Pool
q *db.Queries
db transactionBeginner
}

func NewRepository(q *db.Queries, pool *pgxpool.Pool) *Repository {
return &Repository{q: q, pool: pool}
func NewRepository(q *db.Queries, database transactionBeginner) *Repository {
return &Repository{q: q, db: database}
}

func (r *Repository) GetProductByIDAndOrganization(ctx context.Context, orgID, id uuid.UUID) (db.Product, error) {
Expand Down Expand Up @@ -202,8 +206,80 @@ func (r *Repository) FindPreviousPublishedRelease(ctx context.Context, release d
})
}

var (
ErrReleaseAlreadyHasArtifacts = errors.New("target release already has artifacts")
ErrArtifactsOnlyAddedToDraft = errors.New("artifacts can only be added to a draft release")
)

func (r *Repository) InsertUpdateArtifact(ctx context.Context, params db.InsertUpdateArtifactParams) (db.UpdateArtifact, error) {
return r.q.InsertUpdateArtifact(ctx, params)
tx, err := r.db.Begin(ctx)
if err != nil {
return db.UpdateArtifact{}, err
}
defer tx.Rollback(ctx)

var status string
if err := tx.QueryRow(ctx, "SELECT status FROM update_releases WHERE id = $1 FOR UPDATE", params.ReleaseID).Scan(&status); err != nil {
return db.UpdateArtifact{}, err
}
if status != "draft" {
return db.UpdateArtifact{}, ErrArtifactsOnlyAddedToDraft
}
artifact, err := r.q.WithTx(tx).InsertUpdateArtifact(ctx, params)
if err != nil {
return db.UpdateArtifact{}, err
}
if err := tx.Commit(ctx); err != nil {
return db.UpdateArtifact{}, err
}
return artifact, nil
}

// InsertUpdateArtifactsIfReleaseEmpty atomically verifies that releaseID has no
// artifacts and inserts the supplied artifacts. Locking the parent release row
// serializes competing reuse requests before the empty-release check.
func (r *Repository) InsertUpdateArtifactsIfReleaseEmpty(ctx context.Context, releaseID uuid.UUID, artifacts []db.InsertUpdateArtifactParams) ([]db.UpdateArtifact, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)

// Acquiring a FOR UPDATE lock on the parent release serializes this guard
// with other reuse attempts for the same release.
if err := tx.QueryRow(ctx, "SELECT id FROM update_releases WHERE id = $1 FOR UPDATE", releaseID).Scan(new(uuid.UUID)); err != nil {
return nil, err
}

qtx := r.q.WithTx(tx)
release, err := qtx.GetUpdateRelease(ctx, releaseID)
if err != nil {
return nil, err
}
if release.Status != "draft" {
return nil, ErrArtifactsOnlyAddedToDraft
}

existing, err := qtx.ListArtifactsForRelease(ctx, releaseID)
if err != nil {
return nil, err
}
if len(existing) != 0 {
return nil, ErrReleaseAlreadyHasArtifacts
}

inserted := make([]db.UpdateArtifact, 0, len(artifacts))
for _, artifact := range artifacts {
insertedArtifact, err := qtx.InsertUpdateArtifact(ctx, artifact)
if err != nil {
return nil, err
}
inserted = append(inserted, insertedArtifact)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return inserted, nil
}

func (r *Repository) GetArtifact(ctx context.Context, id uuid.UUID) (db.UpdateArtifact, error) {
Expand Down Expand Up @@ -247,7 +323,7 @@ func (r *Repository) ListCompletedDeltaArtifactsForRelease(ctx context.Context,
}

func (r *Repository) CompleteDeltaJobWithArtifact(ctx context.Context, artifact db.InsertUpdateArtifactParams, complete db.CompleteDeltaJobParams) (db.UpdateDeltaJob, error) {
tx, err := r.pool.Begin(ctx)
tx, err := r.db.Begin(ctx)
if err != nil {
return db.UpdateDeltaJob{}, err
}
Expand Down Expand Up @@ -354,14 +430,3 @@ func MustParseProviderConfig(raw []byte) map[string]any {
}
return m
}

func timePtr(t pgtype.Timestamptz) *time.Time {
if t.Valid {
return &t.Time
}
return nil
}

func uuidToPG(id uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(id), Valid: true}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package update

import (
"errors"
"strings"
"testing"

"github.com/google/uuid"
)

func TestReuseArtifactsIntegration(t *testing.T) {
tx, q := openDeltaIntegrationTx(t)
orgID, productID, channelID := seedDeltaParents(t, tx)
sourceID, targetID := uuid.New(), uuid.New()
if _, err := tx.Exec(t.Context(), `
INSERT INTO update_releases (id, organization_id, product_id, channel_id, platform, version, status, published_at)
VALUES ($1, $2, $3, $4, 'macos', '1.0.0', 'published', now()),
($5, $2, $3, $4, 'macos', '1.0.0', 'draft', NULL)`,
sourceID, orgID, productID, channelID, targetID); err != nil {
t.Fatalf("insert releases: %v", err)
}

fullID, deltaID := uuid.New(), uuid.New()
filename := "app.zip"
checksum := strings.Repeat("a", 64)
size := int64(42)
if _, err := tx.Exec(t.Context(), `
INSERT INTO update_artifacts (id, release_id, artifact_type, os, arch, url, size_bytes, checksum_sha256, filename)
VALUES ($1, $2, 'zip', 'macos', 'universal', 'full', $3, $4, $5),
($6, $2, 'delta', 'macos', 'universal', 'delta', 1, $4, 'patch.delta')`,
fullID, sourceID, size, checksum, filename, deltaID); err != nil {
t.Fatalf("insert source artifacts: %v", err)
}

svc := NewService(nil, nil, NewRepository(q, tx), nil, "http://clave.test", t.TempDir())
artifacts, err := svc.ReuseArtifacts(t.Context(), targetID, sourceID)
if err != nil {
t.Fatalf("reuse artifacts: %v", err)
}
if len(artifacts) != 1 || artifacts[0].ArtifactType != "zip" {
t.Fatalf("reused artifacts = %#v, want one full artifact", artifacts)
}

stored, err := q.ListArtifactsForRelease(t.Context(), targetID)
if err != nil {
t.Fatalf("list target artifacts: %v", err)
}
wantKey := fullID.String() + "/" + filename
if len(stored) != 1 || stored[0].StorageKey == nil || *stored[0].StorageKey != wantKey {
t.Fatalf("stored artifacts = %#v, want shared key %q", stored, wantKey)
}

_, err = svc.ReuseArtifacts(t.Context(), targetID, sourceID)
if !errors.Is(err, ErrReleaseAlreadyHasArtifacts) {
t.Fatalf("second reuse error = %v, want %v", err, ErrReleaseAlreadyHasArtifacts)
}
}

func TestReuseArtifactsRejectsInvalidReleasesIntegration(t *testing.T) {
tx, q := openDeltaIntegrationTx(t)
orgID, productID, channelID := seedDeltaParents(t, tx)
sourceID, publishedTargetID, otherPlatformID := uuid.New(), uuid.New(), uuid.New()
if _, err := tx.Exec(t.Context(), `
INSERT INTO update_releases (id, organization_id, product_id, channel_id, platform, version, status, published_at)
VALUES ($1, $2, $3, $4, 'macos', '1.0.0', 'published', now()),
($5, $2, $3, $4, 'macos', '1.1.0', 'published', now()),
($6, $2, $3, $4, 'windows', '1.1.0', 'draft', NULL)`,
sourceID, orgID, productID, channelID, publishedTargetID, otherPlatformID); err != nil {
t.Fatalf("insert releases: %v", err)
}
if _, err := tx.Exec(t.Context(), `
INSERT INTO update_artifacts (id, release_id, artifact_type, os, arch, url)
VALUES ($1, $2, 'zip', 'macos', 'universal', 'full')`, uuid.New(), sourceID); err != nil {
t.Fatalf("insert artifact: %v", err)
}

svc := NewService(nil, nil, NewRepository(q, tx), nil, "http://clave.test", t.TempDir())
if _, err := svc.ReuseArtifacts(t.Context(), publishedTargetID, sourceID); err == nil || !strings.Contains(err.Error(), "draft") {
t.Fatalf("published target error = %v, want draft error", err)
}
if _, err := svc.ReuseArtifacts(t.Context(), otherPlatformID, sourceID); err == nil || !strings.Contains(err.Error(), "same product and platform") {
t.Fatalf("platform mismatch error = %v", err)
}
if _, err := svc.ReuseArtifacts(t.Context(), sourceID, sourceID); err == nil {
t.Fatal("reusing a release onto itself succeeded")
}

}
1 change: 1 addition & 0 deletions backend/internal/features/update/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func (h *Handler) RegisterAdminRoutes(r chi.Router) {
r.Get("/update-releases", h.AdminListReleases)
r.Post("/update-releases", h.AdminCreateRelease)
r.Post("/update-releases/{id}/artifacts", h.AdminUploadArtifact)
r.Post("/update-releases/{id}/artifacts/reuse", h.AdminReuseArtifacts)
r.Post("/update-releases/{id}/publish", h.AdminPublishRelease)
r.Get("/update-releases/{id}/delta-jobs", h.AdminListDeltaJobs)
r.Post("/update-releases/{id}/delta-jobs/retry", h.AdminRetryDeltaJobs)
Expand Down
Loading
Loading