Ctrl K
ComunidadGuías…

internal/expoimport/history.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package expoimport

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"
	"xprem/config"
	"xprem/internal/bucket"
	cache2 "xprem/internal/cache"
	"xprem/internal/crypto"
	"xprem/internal/dashboard"
	"xprem/internal/jobs"
	"xprem/internal/providers/expo"
	"xprem/internal/repository"
	"xprem/internal/types"
	update2 "xprem/internal/update"
	"xprem/internal/validation"

	"github.com/google/uuid"
	"github.com/riverqueue/river"
	"github.com/riverqueue/river/rivertype"
	"golang.org/x/sync/errgroup"
)

var ErrHistoryImportAlreadyRunning = errors.New("an update history import is already running for this app")

var ErrHistoryJobNotFound = errors.New("this history import job does not exist")

const MaxHistoryImportGroups = 50

const expoHistoryJobKind = "expo-history-import"

// The update groups are snapshotted at enqueue time so a retry never needs
// the Expo token, which is not stored.
type expoHistoryArgs struct {
	AppId  string                 `json:"appId" river:"unique"`
	Total  int                    `json:"total"`
	Groups [][]expo.HistoryUpdate `json:"groups"`
}

func (expoHistoryArgs) Kind() string { return expoHistoryJobKind }

func (expoHistoryArgs) InsertOpts() river.InsertOpts {
	return river.InsertOpts{
		MaxAttempts: 5,
		UniqueOpts: river.UniqueOpts{
			ByArgs: true,
			// The default state list also blocks a new job while a finished
			// one is retained; this list only blocks while one is live.
			ByState: []rivertype.JobState{
				rivertype.JobStateAvailable,
				rivertype.JobStatePending,
				rivertype.JobStateRunning,
				rivertype.JobStateRetryable,
				rivertype.JobStateScheduled,
			},
		},
	}
}

type expoHistoryWorker struct {
	river.WorkerDefaults[expoHistoryArgs]
	service *Service
}

func (w *expoHistoryWorker) Timeout(*river.Job[expoHistoryArgs]) time.Duration {
	return time.Hour
}

func (w *expoHistoryWorker) Work(ctx context.Context, job *river.Job[expoHistoryArgs]) error {
	return w.service.copyHistory(ctx, job.Args.AppId, jobs.NewTracker(job.ID), job.Args.Groups)
}

func RegisterWorker(workers *river.Workers, service *Service) {
	river.AddWorker(workers, &expoHistoryWorker{service: service})
}

type HistoryJobStatus struct {
	State           string   `json:"state"`
	Total           int      `json:"total"`
	Processed       int      `json:"processed"`
	Imported        int      `json:"imported"`
	Skipped         []string `json:"skipped,omitempty"`
	Error           string   `json:"error,omitempty"`
	CancelRequested bool     `json:"cancelRequested"`
}

func historyJobStatus(job *rivertype.JobRow) *HistoryJobStatus {
	var args expoHistoryArgs
	_ = json.Unmarshal(job.EncodedArgs, &args)
	output := jobs.OutputOf(job)
	return &HistoryJobStatus{
		State:           jobs.UIState(job),
		Total:           args.Total,
		Processed:       output.Processed,
		Imported:        output.Succeeded,
		Skipped:         output.Warnings,
		Error:           jobs.LastError(job),
		CancelRequested: jobs.CancelRequested(job),
	}
}

func (s *Service) StartHistoryImport(ctx context.Context, auth types.Auth, expoAppId string, limit int) (string, error) {
	if !config.IsDBMode() {
		return "", repository.ErrNotSupportedInStatelessMode
	}
	if err := requireExpoAuth(auth); err != nil {
		return "", err
	}
	parsedId, err := uuid.Parse(strings.TrimSpace(expoAppId))
	if err != nil {
		return "", validation.Errorf("expoAppId", "must be the Expo project UUID (app.json, extra.eas.projectId)")
	}
	if limit < 1 || limit > MaxHistoryImportGroups {
		return "", validation.Errorf("limit", "must be between 1 and %d", MaxHistoryImportGroups)
	}
	appId := parsedId.String()
	if _, err := s.apps.GetAppByID(ctx, appId); err != nil {
		return "", validation.Errorf("expoAppId", "this app is not imported yet, import it first")
	}

	groups, err := expo.FetchUpdateGroups(ctx, auth, appId, limit)
	if err != nil {
		return "", err
	}
	total := 0
	for _, group := range groups {
		total += len(group)
	}

	jobId, err := s.jobs.Enqueue(ctx, expoHistoryArgs{AppId: appId, Total: total, Groups: groups})
	if errors.Is(err, jobs.ErrAlreadyRunning) {
		return "", ErrHistoryImportAlreadyRunning
	}
	return jobId, err
}

func (s *Service) GetAppHistoryJob(ctx context.Context, appId string) (string, *HistoryJobStatus, bool) {
	parsed, err := uuid.Parse(strings.TrimSpace(appId))
	if err != nil {
		return "", nil, false
	}
	job, err := s.jobs.LatestByArg(ctx, expoHistoryJobKind, "appId", parsed.String())
	if err != nil || job == nil {
		return "", nil, false
	}
	return strconv.FormatInt(job.ID, 10), historyJobStatus(job), true
}

func (s *Service) GetHistoryJobStatus(ctx context.Context, jobId string) (*HistoryJobStatus, bool) {
	job, err := s.jobs.Get(ctx, jobId)
	if err != nil || job == nil {
		return nil, false
	}
	return historyJobStatus(job), true
}

// The cancel lands once the update being copied is finished; canceling an
// already finished job is a no-op.
func (s *Service) CancelHistoryJob(ctx context.Context, jobId string) error {
	found, err := s.jobs.Cancel(ctx, jobId)
	if err != nil {
		return err
	}
	if !found {
		return ErrHistoryJobNotFound
	}
	return nil
}

type branchRuntime struct {
	branch  string
	runtime string
}

// copyHistory copies the oldest fetched group first, so a stopped job leaves
// a consistent prefix of history.
func (s *Service) copyHistory(ctx context.Context, appId string, tracker *jobs.Tracker, groups [][]expo.HistoryUpdate) error {
	touched := make(map[branchRuntime]bool)
	defer s.invalidateHistoryServingCaches(appId, touched)

	for groupIndex := len(groups) - 1; groupIndex >= 0; groupIndex-- {
		for _, historyUpdate := range groups[groupIndex] {
			skipReason, err := s.importHistoryUpdate(ctx, appId, historyUpdate, touched)
			// A canceled context outranks whatever the update reported.
			if ctxErr := ctx.Err(); ctxErr != nil {
				return ctxErr
			}
			if err != nil {
				return fmt.Errorf("failed while importing update %s: %w", historyUpdate.Id, err)
			}
			if skipReason != "" {
				tracker.Skip(ctx, fmt.Sprintf("update %s (%s): %s", historyUpdate.Id, historyUpdate.Platform, skipReason))
			} else {
				tracker.Succeed(ctx)
			}
		}
	}
	return nil
}

// importHistoryUpdate returns a non-empty skip reason for problems scoped to
// this update, an error for failures that must stop the job.
func (s *Service) importHistoryUpdate(ctx context.Context, appId string, historyUpdate expo.HistoryUpdate, touched map[branchRuntime]bool) (string, error) {
	platform, ok := parseHistoryPlatform(historyUpdate.Platform)
	if !ok {
		return fmt.Sprintf("platform %q is not supported", historyUpdate.Platform), nil
	}
	// The branch name is validated by the upsert below; the runtime version has
	// no other checkpoint before the insert.
	if err := validation.Name("runtimeVersion", historyUpdate.RuntimeVersion); err != nil {
		return fmt.Sprintf("runtime version %q: %s", historyUpdate.RuntimeVersion, validationMessage(err)), nil
	}
	if historyUpdate.CodeSigned {
		return "signed with Expo code signing, its signature cannot be carried over", nil
	}
	createdAt, err := time.Parse(time.RFC3339, historyUpdate.CreatedAt)
	if err != nil {
		return fmt.Sprintf("unparseable publication date %q", historyUpdate.CreatedAt), nil
	}
	expoUpdateUUID, err := uuid.Parse(historyUpdate.Id)
	if err != nil {
		return fmt.Sprintf("unparseable update id %q", historyUpdate.Id), nil
	}

	// Same id scheme as GenerateUpdateTimestamp, but derived from the original
	// EAS publication instant so the branch timeline keeps its order.
	updateId := createdAt.UnixMilli()*10 + historyPlatformDigit(platform)
	// An occupied slot: already imported, or published locally at this exact
	// instant; writing this update's files would overwrite its own.
	occupied, err := s.updateRepo.UpdateExists(ctx, appId, historyUpdate.BranchName, updateId)
	if err != nil {
		return "", err
	}
	if occupied {
		return "an update already exists at this instant on this branch", nil
	}

	if err := s.branches.UpsertBranchAndRuntimeVersion(ctx, appId, historyUpdate.BranchName, historyUpdate.RuntimeVersion); err != nil {
		var valErr *validation.Error
		if errors.As(err, &valErr) {
			return fmt.Sprintf("branch %q: %s", historyUpdate.BranchName, valErr.Message), nil
		}
		return "", fmt.Errorf("failed to upsert branch %q and runtime version %q: %w", historyUpdate.BranchName, historyUpdate.RuntimeVersion, err)
	}

	params := repository.ImportUpdateParams{
		AppId:          appId,
		UpdateId:       updateId,
		BranchName:     historyUpdate.BranchName,
		RuntimeVersion: historyUpdate.RuntimeVersion,
		Platform:       platform,
		CommitHash:     historyUpdate.GitCommitHash,
		Message:        historyUpdate.Message,
		CreatedAt:      createdAt,
		CheckedAt:      createdAt,
	}
	if group, err := uuid.Parse(historyUpdate.Group); err == nil {
		groupStr := group.String()
		params.PublishGroup = &groupStr
	}

	if historyUpdate.IsRollBack {
		params.UpdateType = types.Rollback
		if _, err := s.updateRepo.ImportUpdate(ctx, params); err != nil {
			return "", err
		}
		touched[branchRuntime{branch: historyUpdate.BranchName, runtime: historyUpdate.RuntimeVersion}] = true
		return "", nil
	}

	if historyUpdate.ManifestPermalink == "" {
		return "the update carries no manifest permalink", nil
	}
	served, err := expo.FetchServedManifest(ctx, historyUpdate.ManifestPermalink)
	if err != nil {
		return err.Error(), nil
	}
	if served.Manifest.LaunchAsset.Url == "" {
		return "the manifest carries no launch asset", nil
	}

	update := types.Update{
		AppId:          appId,
		Branch:         historyUpdate.BranchName,
		RuntimeVersion: historyUpdate.RuntimeVersion,
		UpdateId:       strconv.FormatInt(updateId, 10),
	}
	// Blobs first: on a skip nothing has landed in the update folder yet, and
	// cas blobs are shared so they are never cleaned up.
	mapping, skipReason, err := s.storeHistoryBlobs(ctx, appId, served)
	if err != nil {
		return "", err
	}
	if skipReason != "" {
		return skipReason, nil
	}
	if err := s.writeHistoryConfigFiles(ctx, update, platform, historyUpdate, &served.Manifest); err != nil {
		s.deleteHistoryUpdateFolder(ctx, update)
		return "", err
	}

	expoUpdateUUIDStr := expoUpdateUUID.String()
	params.UpdateType = types.NormalUpdate
	params.UpdateUUID = &expoUpdateUUIDStr
	params.AssetMapping = mapping
	inserted, err := s.updateRepo.ImportUpdate(ctx, params)
	if err != nil {
		// Without the row the config files are unreachable orphans.
		s.deleteHistoryUpdateFolder(ctx, update)
		return "", err
	}
	touched[branchRuntime{branch: historyUpdate.BranchName, runtime: historyUpdate.RuntimeVersion}] = true
	if !inserted {
		log.Printf("[expo-import] timeline slot %s/%s already occupied; bucket files for update %s may have been overwritten", historyUpdate.BranchName, update.UpdateId, historyUpdate.Id)
		return "an update already exists at this instant on this branch; its files may have been overwritten", nil
	}
	return "", nil
}

const historyDownloadConcurrency = 8

// skipUpdate is a per-update problem: it skips the update instead of failing the job.
type skipUpdate struct{ reason string }

func (e *skipUpdate) Error() string { return e.reason }

// storeHistoryBlobs puts every asset of the served manifest in cas/ and
// returns the update's asset mapping. EAS hashes are base64url SHA-256, the
// exact cas key format, so a hash already in cas is not even downloaded.
// Download and integrity problems return a skip reason, cas writes an error.
func (s *Service) storeHistoryBlobs(ctx context.Context, appId string, served *expo.ServedManifest) (*types.UpdateAssetMapping, string, error) {
	manifest := &served.Manifest
	mapping := &types.UpdateAssetMapping{
		LaunchAsset: shapeHistoryAsset(manifest.LaunchAsset, true),
		Assets:      make([]types.ShapedAsset, len(manifest.Assets)),
	}
	sources := []expo.HistoryAsset{manifest.LaunchAsset}
	for i, asset := range manifest.Assets {
		mapping.Assets[i] = shapeHistoryAsset(asset, false)
		sources = append(sources, asset)
	}

	var mu sync.Mutex
	hashByURL := make(map[string]string, len(sources))
	group, groupCtx := errgroup.WithContext(ctx)
	group.SetLimit(historyDownloadConcurrency)
	seenURLs := make(map[string]bool, len(sources))
	for _, asset := range sources {
		if asset.Url == "" {
			return nil, fmt.Sprintf("asset %q carries no download URL", asset.Key), nil
		}
		if seenURLs[asset.Url] {
			continue
		}
		seenURLs[asset.Url] = true
		group.Go(func() error {
			hash, err := s.ensureHistoryBlob(groupCtx, appId, asset, served.AssetRequestHeaders[asset.Key])
			if err != nil {
				return err
			}
			mu.Lock()
			hashByURL[asset.Url] = hash
			mu.Unlock()
			return nil
		})
	}
	if err := group.Wait(); err != nil {
		var skip *skipUpdate
		if errors.As(err, &skip) {
			return nil, skip.reason, nil
		}
		return nil, "", err
	}

	// An asset the manifest did not hash gets the one computed from its bytes.
	mapping.LaunchAsset.Hash = hashByURL[manifest.LaunchAsset.Url]
	for i, asset := range manifest.Assets {
		mapping.Assets[i].Hash = hashByURL[asset.Url]
	}
	return mapping, "", nil
}

// ensureHistoryBlob returns the cas hash of the asset, downloading and storing
// it only when the blob is not already there.
func (s *Service) ensureHistoryBlob(ctx context.Context, appId string, asset expo.HistoryAsset, headers map[string]string) (string, error) {
	declaredHash := asset.Hash
	if bucket.ValidateBlobHash(declaredHash) != nil {
		declaredHash = ""
	}
	if declaredHash != "" {
		exists, err := s.blobStore.Exists(ctx, appId, declaredHash)
		if err != nil {
			return "", fmt.Errorf("failed to check cas for asset %q: %w", asset.Key, err)
		}
		if exists {
			return declaredHash, nil
		}
	}
	data, err := expo.DownloadAsset(ctx, asset.Url, headers)
	if err != nil {
		return "", &skipUpdate{reason: err.Error()}
	}
	hash, err := crypto.CreateHash(data, "sha256", "base64")
	if err != nil {
		return "", err
	}
	computedHash := crypto.GetBase64URLEncoding(hash)
	if declaredHash != "" && computedHash != declaredHash {
		return "", &skipUpdate{reason: fmt.Sprintf("asset %q does not match its manifest hash", asset.Key)}
	}
	if err := s.blobStore.Put(ctx, appId, computedHash, bytes.NewReader(data)); err != nil {
		return "", fmt.Errorf("failed to write asset %q into cas: %w", asset.Key, err)
	}
	return computedHash, nil
}

// shapeHistoryAsset mirrors the publish-side shaping: the launch asset serves
// as .bundle whatever EAS called it.
func shapeHistoryAsset(asset expo.HistoryAsset, isLaunchAsset bool) types.ShapedAsset {
	shaped := types.ShapedAsset{
		Hash:          asset.Hash,
		Key:           asset.Key,
		FileExtension: asset.FileExtension,
		ContentType:   asset.ContentType,
	}
	if isLaunchAsset {
		shaped.FileExtension = ".bundle"
		if shaped.ContentType == "" {
			shaped.ContentType = "application/javascript"
		}
	}
	return shaped
}

// writeHistoryConfigFiles writes the update folder's config files; the assets
// themselves live in cas/.
func (s *Service) writeHistoryConfigFiles(ctx context.Context, update types.Update, platform types.Platform, historyUpdate expo.HistoryUpdate, manifest *expo.HistoryManifest) error {
	platformMetadata := types.PlatformMetadata{
		Bundle: "bundles/" + string(platform) + "-" + historyAssetFileName(manifest.LaunchAsset, 0) + ".bundle",
	}
	seenPaths := map[string]bool{platformMetadata.Bundle: true}
	for index, asset := range manifest.Assets {
		path := "assets/" + historyAssetFileName(asset, index)
		if seenPaths[path] {
			continue
		}
		seenPaths[path] = true
		platformMetadata.Assets = append(platformMetadata.Assets, types.Asset{
			Path: path,
			Ext:  strings.TrimPrefix(asset.FileExtension, "."),
		})
	}

	metadata := types.MetadataObject{Version: 0, Bundler: "metro"}
	if platform == types.PlatformIOS {
		metadata.FileMetadata.IOS = platformMetadata
	} else {
		metadata.FileMetadata.Android = platformMetadata
	}
	metadataBytes, err := json.Marshal(metadata)
	if err != nil {
		return err
	}
	if err := s.updateStore.PutFile(ctx, update, "metadata.json", bytes.NewReader(metadataBytes)); err != nil {
		return fmt.Errorf("failed to write metadata.json into the bucket: %w", err)
	}

	if expoClient := manifest.ExpoClientConfig(); expoClient != nil {
		expoConfigBytes, err := json.Marshal(expoClient)
		if err != nil {
			return err
		}
		if err := s.updateStore.PutFile(ctx, update, "expoConfig.json", bytes.NewReader(expoConfigBytes)); err != nil {
			return fmt.Errorf("failed to write expoConfig.json into the bucket: %w", err)
		}
	}

	// The stored UUID is the original EAS update id: manifests keep the id
	// devices already know, so a migrated fleet does not re-download content
	// it is already running.
	storedMetadata := types.UpdateStoredMetadata{
		Platform:   platform,
		CommitHash: historyUpdate.GitCommitHash,
		UpdateUUID: historyUpdate.Id,
		Message:    historyUpdate.Message,
	}
	storedMetadataBytes, err := json.Marshal(storedMetadata)
	if err != nil {
		return err
	}
	if err := s.updateStore.PutFile(ctx, update, "update-metadata.json", bytes.NewReader(storedMetadataBytes)); err != nil {
		return fmt.Errorf("failed to write update-metadata.json into the bucket: %w", err)
	}
	return nil
}

var historyAssetKeyPattern = regexp.MustCompile(`[^a-zA-Z0-9._-]`)

func historyAssetFileName(asset expo.HistoryAsset, index int) string {
	name := historyAssetKeyPattern.ReplaceAllString(asset.Key, "")
	if strings.Trim(name, "._-") == "" {
		return fmt.Sprintf("asset-%d", index)
	}
	return name
}

func (s *Service) invalidateHistoryServingCaches(appId string, touched map[branchRuntime]bool) {
	cache := cache2.GetCache()
	keys := []string{
		dashboard.ComputeGetBranchesCacheKey(appId),
		dashboard.ComputeGetChannelsCacheKey(appId),
	}
	for entry := range touched {
		keys = append(keys,
			dashboard.ComputeGetRuntimeVersionsCacheKey(appId, entry.branch),
			update2.ComputeLastUpdateCacheKey(appId, entry.branch, entry.runtime, types.PlatformIOS),
			update2.ComputeLastUpdateCacheKey(appId, entry.branch, entry.runtime, types.PlatformAndroid),
		)
	}
	for _, key := range keys {
		cache.Delete(key)
	}
}

func (s *Service) deleteHistoryUpdateFolder(ctx context.Context, update types.Update) {
	// The cleanup often follows a canceled job; it must still run.
	if err := s.updateStore.Delete(context.WithoutCancel(ctx), update.AppId, update.Branch, update.RuntimeVersion, update.UpdateId); err != nil {
		log.Printf("[expo-import] failed to clean up update folder %s: %v", update.UpdateId, err)
	}
}

func parseHistoryPlatform(platform string) (types.Platform, bool) {
	switch strings.ToLower(platform) {
	case string(types.PlatformIOS):
		return types.PlatformIOS, true
	case string(types.PlatformAndroid):
		return types.PlatformAndroid, true
	default:
		return "", false
	}
}

func historyPlatformDigit(platform types.Platform) int64 {
	if platform == types.PlatformIOS {
		return 1
	}
	return 2
}