Scout/internal/registry/targets.go
2026-08-31 15:34:36 +08:00

646 lines
19 KiB
Go

package registry
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"scout/pkg/scoutpkg"
)
var allowedChannels = map[string]bool{
"stable": true,
"beta": true,
"dev": true,
}
var allowedStatuses = map[string]bool{
"draft": true,
"published": true,
"deprecated": true,
"revoked": true,
}
func (r *Registry) AddPackage(input Package, data io.Reader) (Package, error) {
target := Target{
Platform: input.Platform,
Architecture: input.Architecture,
PackageType: input.PackageType,
FileName: input.FileName,
}
return r.AddArtifact(input, target, data)
}
func (r *Registry) AddArtifact(input Package, target Target, data io.Reader) (Package, error) {
if data == nil {
return Package{}, errors.New("\u5305\u6587\u4ef6\u4e0d\u80fd\u4e3a\u7a7a")
}
if err := normalizeRelease(&input); err != nil {
return Package{}, err
}
if target.FileName == "" {
target.FileName = input.FileName
}
if target.FileName == "" {
target.FileName = input.Name + "-" + input.Version + ".pkg"
}
if err := normalizeTarget(&target, input.PackageType); err != nil {
return Package{}, err
}
r.writeMu.Lock()
defer r.writeMu.Unlock()
stored := r.release(input.Name, input.Version)
if stored.Name == "" {
stored = input
stored.Targets = nil
} else if err := mergeReleaseMetadata(&stored, input); err != nil {
return Package{}, err
}
for _, existing := range stored.Targets {
if existing.ID == target.ID {
return Package{}, errors.New("\u8be5\u7248\u672c\u7684\u5e73\u53f0\u3001\u67b6\u6784\u548c\u5305\u7c7b\u578b\u5df2\u7ecf\u5b58\u5728")
}
}
versionDir := filepath.Join(r.root, input.Name, input.Version)
if err := os.MkdirAll(versionDir, 0o755); err != nil {
return Package{}, fmt.Errorf("\u521b\u5efa\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err)
}
tempPath, size, digest, err := writeTemporaryArtifact(versionDir, data)
if err != nil {
return Package{}, err
}
defer os.Remove(tempPath)
target.Size = size
target.SHA256 = digest
target.ArtifactID = target.ID
artifactDir := filepath.Join(versionDir, "artifacts", target.ArtifactID)
if _, err := os.Stat(artifactDir); err == nil {
return Package{}, errors.New("\u8be5\u4ea7\u7269\u5df2\u7ecf\u5b58\u5728")
} else if !os.IsNotExist(err) {
return Package{}, fmt.Errorf("\u68c0\u67e5\u4ea7\u7269\u76ee\u5f55\u5931\u8d25: %w", err)
}
if err := saveArtifact(artifactDir, tempPath, target); err != nil {
return Package{}, err
}
stored.Targets = append(stored.Targets, target)
stored.UploadedAt = time.Now().UTC()
applyTarget(&stored, target)
if err := r.persistRelease(stored); err != nil {
return Package{}, err
}
r.storeRelease(stored)
result := stored
result.Targets = []Target{target}
applyTarget(&result, target)
r.broadcast(result)
return result, nil
}
func (r *Registry) AddScoutPackage(input Package, targets []Target, data io.Reader) (Package, error) {
if data == nil {
return Package{}, errors.New("Scout \u5305\u6587\u4ef6\u4e0d\u80fd\u4e3a\u7a7a")
}
if err := normalizeRelease(&input); err != nil {
return Package{}, err
}
if len(targets) == 0 {
return Package{}, errors.New("Scout \u5305\u81f3\u5c11\u9700\u8981\u4e00\u4e2a\u76ee\u6807")
}
seen := make(map[string]bool)
for index := range targets {
if targets[index].FileName == "" {
targets[index].FileName = input.FileName
}
if err := normalizeTarget(&targets[index], input.PackageType); err != nil {
return Package{}, err
}
if seen[targets[index].ID] {
return Package{}, errors.New("Scout \u5305\u6e05\u5355\u4e2d\u5b58\u5728\u91cd\u590d\u76ee\u6807")
}
seen[targets[index].ID] = true
}
r.writeMu.Lock()
defer r.writeMu.Unlock()
stored := r.release(input.Name, input.Version)
if stored.Name == "" {
stored = input
stored.Targets = nil
} else if err := mergeReleaseMetadata(&stored, input); err != nil {
return Package{}, err
}
for _, existing := range stored.Targets {
if seen[existing.ID] {
return Package{}, errors.New("Scout \u5305\u4e2d\u7684\u67d0\u4e2a\u5e73\u53f0\u76ee\u6807\u5df2\u7ecf\u5b58\u5728")
}
}
versionDir := filepath.Join(r.root, input.Name, input.Version)
if err := os.MkdirAll(versionDir, 0o755); err != nil {
return Package{}, fmt.Errorf("\u521b\u5efa\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err)
}
tempPath, size, digest, err := writeTemporaryArtifact(versionDir, data)
if err != nil {
return Package{}, err
}
defer os.Remove(tempPath)
artifactID := "scout-" + digest[:16]
artifactDir := filepath.Join(versionDir, "artifacts", artifactID)
if _, err := os.Stat(artifactDir); err == nil {
return Package{}, errors.New("\u8be5 Scout \u4ea7\u7269\u5df2\u7ecf\u5b58\u5728")
} else if !os.IsNotExist(err) {
return Package{}, fmt.Errorf("\u68c0\u67e5 Scout \u4ea7\u7269\u5931\u8d25: %w", err)
}
artifactMetadata := targets[0]
artifactMetadata.ID = artifactID
artifactMetadata.ArtifactID = artifactID
artifactMetadata.Size = size
artifactMetadata.SHA256 = digest
if err := saveArtifact(artifactDir, tempPath, artifactMetadata); err != nil {
return Package{}, err
}
for index := range targets {
targets[index].ArtifactID = artifactID
targets[index].Size = size
targets[index].SHA256 = digest
}
stored.Targets = append(stored.Targets, targets...)
stored.UploadedAt = time.Now().UTC()
applyTarget(&stored, targets[0])
if err := r.persistRelease(stored); err != nil {
return Package{}, err
}
r.storeRelease(stored)
result := stored
result.Targets = append([]Target(nil), targets...)
applyTarget(&result, targets[0])
r.broadcast(result)
return result, nil
}
func (r *Registry) LatestFor(name, platform, architecture, channel, currentVersion string) (Package, bool) {
r.mu.RLock()
versions := make([]Package, 0, len(r.packages[name]))
for _, item := range r.packages[name] {
versions = append(versions, clonePackage(item))
}
r.mu.RUnlock()
sort.Slice(versions, func(left, right int) bool {
return CompareVersions(versions[left].Version, versions[right].Version) > 0
})
for _, item := range versions {
if !releaseVisible(item, channel) {
continue
}
selected, ok := SelectTarget(item, platform, architecture, currentVersion)
if ok {
return selected, true
}
}
return Package{}, false
}
func (r *Registry) LatestForVersion(name, version, platform, architecture, channel, currentVersion string) (Package, bool) {
return r.GetFor(name, version, platform, architecture, channel, currentVersion)
}
func (r *Registry) VersionsFor(name, platform, architecture, channel, currentVersion string) []Package {
r.mu.RLock()
versions := make([]Package, 0, len(r.packages[name]))
for _, item := range r.packages[name] {
versions = append(versions, clonePackage(item))
}
r.mu.RUnlock()
result := make([]Package, 0, len(versions))
for _, item := range versions {
if !releaseVisible(item, channel) {
continue
}
if selected, ok := SelectTarget(item, platform, architecture, currentVersion); ok {
result = append(result, selected)
}
}
sort.Slice(result, func(left, right int) bool {
return CompareVersions(result[left].Version, result[right].Version) > 0
})
return result
}
func (r *Registry) GetFor(name, version, platform, architecture, channel, currentVersion string) (Package, bool) {
r.mu.RLock()
item, ok := r.packages[name][version]
if ok {
item = clonePackage(item)
}
r.mu.RUnlock()
if !ok || !releaseVisible(item, channel) {
return Package{}, false
}
return SelectTarget(item, platform, architecture, currentVersion)
}
func (r *Registry) OpenPackageFor(name, version, platform, architecture, currentVersion string) (*os.File, Package, error) {
item, ok := r.GetFor(name, version, platform, architecture, "", currentVersion)
if !ok || len(item.Targets) == 0 {
return nil, Package{}, os.ErrNotExist
}
target := item.Targets[0]
packagePath := filepath.Join(r.root, name, version, "artifacts", target.ArtifactID, "package.bin")
if target.ArtifactID == "legacy" {
packagePath = filepath.Join(r.root, name, version, "package.bin")
}
file, err := os.Open(packagePath)
if err != nil {
return nil, Package{}, err
}
return file, item, nil
}
func (r *Registry) ListFor(platform, architecture, channel, currentVersion string) []Package {
r.mu.RLock()
names := make([]string, 0, len(r.packages))
for name := range r.packages {
names = append(names, name)
}
r.mu.RUnlock()
sort.Strings(names)
result := make([]Package, 0, len(names))
for _, name := range names {
if item, ok := r.LatestFor(name, platform, architecture, channel, currentVersion); ok {
result = append(result, item)
}
}
return result
}
func SelectTarget(item Package, platform, architecture, currentVersion string) (Package, bool) {
if len(item.Targets) == 0 {
return Package{}, false
}
platform = strings.TrimSpace(platform)
architecture = strings.TrimSpace(architecture)
if platform == "" && architecture == "" {
selected := item.Targets[0]
result := clonePackage(item)
result.Targets = []Target{selected}
applyTarget(&result, selected)
return result, true
}
normalizedPlatform, err := scoutpkg.NormalizePlatform(platform)
if err != nil {
return Package{}, false
}
normalizedArchitecture, err := scoutpkg.NormalizeArchitecture(architecture)
if err != nil {
return Package{}, false
}
bestIndex := -1
bestScore := 1000
for index, target := range item.Targets {
score, compatible := targetScore(target, normalizedPlatform, normalizedArchitecture, currentVersion)
if compatible && score < bestScore {
bestIndex = index
bestScore = score
}
}
if bestIndex < 0 {
return Package{}, false
}
selected := item.Targets[bestIndex]
result := clonePackage(item)
result.Targets = []Target{selected}
applyTarget(&result, selected)
return result, true
}
func targetScore(target Target, platform, architecture, currentVersion string) (int, bool) {
platformScore := -1
switch {
case target.Platform == platform && target.Architecture == architecture:
platformScore = 0
case target.Platform == platform && target.Architecture == "universal":
platformScore = 10
case target.Platform == "any" && target.Architecture == architecture:
platformScore = 20
case target.Platform == "any" && target.Architecture == "universal":
platformScore = 30
}
if platformScore < 0 {
return 0, false
}
if target.PackageType == "delta" {
if currentVersion == "" || !containsVersion(target.UpgradeFrom, currentVersion) {
return 0, false
}
return platformScore, true
}
return platformScore + 1, true
}
func normalizeRelease(item *Package) error {
if err := ValidatePackage(item.Name, item.Version); err != nil {
return err
}
item.Channel = strings.ToLower(strings.TrimSpace(item.Channel))
if item.Channel == "" {
item.Channel = "stable"
}
if !allowedChannels[item.Channel] {
return errors.New("\u53d1\u5e03\u901a\u9053\u53ea\u80fd\u662f stable\u3001beta \u6216 dev")
}
item.Status = strings.ToLower(strings.TrimSpace(item.Status))
if item.Status == "" {
item.Status = "published"
}
if !allowedStatuses[item.Status] {
return errors.New("\u53d1\u5e03\u72b6\u6001\u65e0\u6548")
}
item.PackageType = normalizePackageType(item.PackageType)
if item.FileName != "" {
item.FileName = filepath.Base(item.FileName)
if err := ValidateFileName(item.FileName); err != nil {
return err
}
}
return nil
}
func normalizeTarget(target *Target, fallbackType string) error {
platform := target.Platform
if strings.TrimSpace(platform) == "" {
platform = "any"
}
normalizedPlatform, err := scoutpkg.NormalizePlatform(platform)
if err != nil {
return err
}
architecture := target.Architecture
if strings.TrimSpace(architecture) == "" {
architecture = "universal"
}
normalizedArchitecture, err := scoutpkg.NormalizeArchitecture(architecture)
if err != nil {
return err
}
target.Platform = normalizedPlatform
target.Architecture = normalizedArchitecture
if target.PackageType == "" {
target.PackageType = fallbackType
}
target.PackageType = normalizePackageType(target.PackageType)
if target.PackageType == "delta" && len(target.UpgradeFrom) == 0 {
return errors.New("\u5dee\u5206\u5305\u5fc5\u987b\u6307\u5b9a upgrade_from")
}
for _, version := range target.UpgradeFrom {
if !versionPattern.MatchString(version) {
return errors.New("upgrade_from \u5305\u542b\u65e0\u6548\u7248\u672c")
}
}
target.FileName = filepath.Base(strings.TrimSpace(target.FileName))
if err := ValidateFileName(target.FileName); err != nil {
return err
}
target.ID = targetIdentity(*target)
return nil
}
func normalizePackage(item *Package) {
if item.Channel == "" {
item.Channel = "stable"
}
if item.Status == "" {
item.Status = "published"
}
if item.PackageType == "" {
item.PackageType = "full"
}
for index := range item.Targets {
if item.Targets[index].PackageType == "" {
item.Targets[index].PackageType = item.PackageType
}
}
}
func normalizePackageType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "delta", "patch", "differential":
return "delta"
default:
return "full"
}
}
func targetIdentity(target Target) string {
identity := target.Platform + "-" + target.Architecture + "-" + target.PackageType
if target.PackageType == "delta" {
versions := append([]string(nil), target.UpgradeFrom...)
sort.Strings(versions)
digest := sha256.Sum256([]byte(strings.Join(versions, ",")))
identity += "-" + hex.EncodeToString(digest[:6])
}
return identity
}
func mergeReleaseMetadata(stored *Package, input Package) error {
if stored.Channel != input.Channel {
return errors.New("\u540c\u4e00\u7248\u672c\u4e0d\u80fd\u4f7f\u7528\u4e0d\u540c\u53d1\u5e03\u901a\u9053")
}
if stored.Status != input.Status {
return errors.New("\u540c\u4e00\u7248\u672c\u4e0d\u80fd\u4f7f\u7528\u4e0d\u540c\u53d1\u5e03\u72b6\u6001")
}
if stored.Description == "" {
stored.Description = input.Description
}
return nil
}
func writeTemporaryArtifact(versionDir string, data io.Reader) (string, int64, string, error) {
temp, err := os.CreateTemp(versionDir, ".upload-*")
if err != nil {
return "", 0, "", fmt.Errorf("\u521b\u5efa\u4e34\u65f6\u6587\u4ef6\u5931\u8d25: %w", err)
}
tempPath := temp.Name()
hash := sha256.New()
size, copyErr := io.Copy(io.MultiWriter(temp, hash), data)
closeErr := temp.Close()
if copyErr != nil {
return tempPath, 0, "", fmt.Errorf("\u5199\u5165\u5305\u6587\u4ef6\u5931\u8d25: %w", copyErr)
}
if closeErr != nil {
return tempPath, 0, "", fmt.Errorf("\u5173\u95ed\u5305\u6587\u4ef6\u5931\u8d25: %w", closeErr)
}
if size == 0 {
return tempPath, 0, "", errors.New("\u5305\u6587\u4ef6\u4e0d\u80fd\u4e3a\u7a7a")
}
return tempPath, size, hex.EncodeToString(hash.Sum(nil)), nil
}
func saveArtifact(artifactDir, tempPath string, metadata Target) error {
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
return fmt.Errorf("\u521b\u5efa\u4ea7\u7269\u76ee\u5f55\u5931\u8d25: %w", err)
}
packagePath := filepath.Join(artifactDir, "package.bin")
if err := os.Rename(tempPath, packagePath); err != nil {
return fmt.Errorf("\u4fdd\u5b58\u4ea7\u7269\u6587\u4ef6\u5931\u8d25: %w", err)
}
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("\u7f16\u7801\u4ea7\u7269\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
if err := os.WriteFile(filepath.Join(artifactDir, "metadata.json"), data, 0o644); err != nil {
return fmt.Errorf("\u4fdd\u5b58\u4ea7\u7269\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
return nil
}
func (r *Registry) persistRelease(item Package) error {
data, err := json.MarshalIndent(item, "", " ")
if err != nil {
return fmt.Errorf("\u7f16\u7801\u53d1\u5e03\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
path := filepath.Join(r.root, item.Name, item.Version, "release.json")
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("\u4fdd\u5b58\u53d1\u5e03\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
return nil
}
func (r *Registry) release(name, version string) Package {
r.mu.RLock()
defer r.mu.RUnlock()
return clonePackage(r.packages[name][version])
}
func (r *Registry) storeRelease(item Package) {
r.mu.Lock()
defer r.mu.Unlock()
if r.packages[item.Name] == nil {
r.packages[item.Name] = make(map[string]Package)
}
r.packages[item.Name][item.Version] = clonePackage(item)
}
func (r *Registry) broadcast(item Package) {
if item.Status != "published" {
return
}
r.mu.RLock()
subscribers := make([]chan Update, 0, len(r.subs))
for _, subscriber := range r.subs {
subscribers = append(subscribers, subscriber)
}
r.mu.RUnlock()
update := Update{Package: item}
for _, subscriber := range subscribers {
select {
case subscriber <- update:
default:
}
}
}
func releaseVisible(item Package, channel string) bool {
if item.Status != "published" {
return false
}
channel = strings.ToLower(strings.TrimSpace(channel))
return channel == "" || item.Channel == channel
}
func applyTarget(item *Package, target Target) {
item.Platform = target.Platform
item.Architecture = target.Architecture
item.PackageType = target.PackageType
item.FileName = target.FileName
item.Size = target.Size
item.SHA256 = target.SHA256
}
func clonePackage(item Package) Package {
item.Targets = append([]Target(nil), item.Targets...)
for index := range item.Targets {
item.Targets[index].UpgradeFrom = append([]string(nil), item.Targets[index].UpgradeFrom...)
}
return item
}
func containsVersion(versions []string, wanted string) bool {
for _, version := range versions {
if version == wanted {
return true
}
}
return false
}
func (r *Registry) loadReleases() error {
r.mu.Lock()
for name, versions := range r.packages {
for version, item := range versions {
normalizePackage(&item)
if len(item.Targets) == 0 {
item.Targets = []Target{{
ID: "legacy",
Platform: "any",
Architecture: "universal",
PackageType: "full",
FileName: item.FileName,
Size: item.Size,
SHA256: item.SHA256,
ArtifactID: "legacy",
}}
}
versions[version] = item
}
r.packages[name] = versions
}
r.mu.Unlock()
packages, err := os.ReadDir(r.root)
if err != nil {
return fmt.Errorf("\u8bfb\u53d6\u5b58\u50a8\u76ee\u5f55\u5931\u8d25: %w", err)
}
for _, packageEntry := range packages {
if !packageEntry.IsDir() || !packageNamePattern.MatchString(packageEntry.Name()) {
continue
}
versions, err := os.ReadDir(filepath.Join(r.root, packageEntry.Name()))
if err != nil {
return fmt.Errorf("\u8bfb\u53d6\u5305\u76ee\u5f55\u5931\u8d25: %w", err)
}
for _, versionEntry := range versions {
if !versionEntry.IsDir() || !versionPattern.MatchString(versionEntry.Name()) {
continue
}
data, err := os.ReadFile(filepath.Join(r.root, packageEntry.Name(), versionEntry.Name(), "release.json"))
if err != nil {
continue
}
var item Package
if err := json.Unmarshal(data, &item); err != nil {
return fmt.Errorf("\u8bfb\u53d6\u53d1\u5e03\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
normalizePackage(&item)
r.storeRelease(item)
}
}
return nil
}