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
19 changes: 16 additions & 3 deletions internal/cli/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,12 @@ func (rm *ReleaseManager) buildArtifactsJSON(
continue
}

// Platform-specific artifact
// Use version + platform as key to avoid overwriting different versions of the same platform
platformKey := fmt.Sprintf("%s-%s-%s", fileInfo.Version, fileInfo.OS, fileInfo.Arch)
// Platform-specific artifact.
// Include the binary name so a platform can publish more than one
// main file (Tomcat Windows zip and the service installer .exe).
// Sidecars share that name once their suffix is removed, so the
// audit and signature stay attached to the matching binary.
platformKey := fmt.Sprintf("%s-%s-%s-%s", fileInfo.Version, fileInfo.OS, fileInfo.Arch, artifactStem(originalFilename))
basePlatformKey := fmt.Sprintf("%s-%s", fileInfo.OS, fileInfo.Arch)
if _, exists := platforms[platformKey]; !exists {
platforms[platformKey] = &storage.PlatformArtifact{
Expand Down Expand Up @@ -1058,6 +1061,16 @@ func contains(slice []string, val string) bool {
return false
}

// artifactStem returns the main binary name for a file and its sidecars.
func artifactStem(filename string) string {
for _, suffix := range []string{".audit.json", ".metadata.json", ".sha512", ".asc", ".sig", ".cert"} {
if strings.HasSuffix(filename, suffix) {
return strings.TrimSuffix(filename, suffix)
}
}
return filename
}

// extractVersionFromFilename extracts version string from a filename.
// Example: "node-v22.15.0-linux-x64.tar.xz" -> "22.15.0"
func extractVersionFromFilename(filename string) string {
Expand Down
92 changes: 92 additions & 0 deletions internal/cli/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,98 @@ func TestBuildArtifactsJSON_RPMPackageWithAudit(t *testing.T) {
}
}

func TestBuildArtifactsJSON_KeepsTomcatZipAndExe(t *testing.T) {
rm := &ReleaseManager{
db: &mockDatabaseStore{},
github: &mockGitHubReleaser{},
stdout: slog.New(slog.NewJSONHandler(io.Discard, nil)),
stderr: slog.New(slog.NewJSONHandler(io.Discard, nil)),
}

uploaded := map[string]artifactInfo{
"apache-tomcat-10.1.60-windows-x64.zip": {
URL: "https://example.com/apache-tomcat-10.1.60-windows-x64.zip",
},
"apache-tomcat-10.1.60-windows-x64.zip.asc": {
URL: "https://example.com/apache-tomcat-10.1.60-windows-x64.zip.asc",
},
"apache-tomcat-10.1.60-windows-x64.zip.audit.json": {
URL: "https://example.com/apache-tomcat-10.1.60-windows-x64.zip.audit.json",
},
"apache-tomcat-10.1.60.exe": {
URL: "https://example.com/apache-tomcat-10.1.60.exe",
},
"apache-tomcat-10.1.60.exe.asc": {
URL: "https://example.com/apache-tomcat-10.1.60.exe.asc",
},
"apache-tomcat-10.1.60.exe.audit.json": {
URL: "https://example.com/apache-tomcat-10.1.60.exe.audit.json",
},
"apache-tomcat-10.1.60.tar.gz": {
URL: "https://example.com/apache-tomcat-10.1.60.tar.gz",
},
}
results := []runtime.DownloadResult{
{
LocalPath: "/tmp/windows-x64/apache-tomcat-10.1.60-windows-x64.zip",
Platform: platform.Platform{OS: "windows", Arch: "x64"},
Version: "10.1.60",
},
{
LocalPath: "/tmp/windows-x64/apache-tomcat-10.1.60.exe",
Platform: platform.Platform{OS: "windows", Arch: "x64"},
Version: "10.1.60",
},
{
LocalPath: "/tmp/linux-x64/apache-tomcat-10.1.60.tar.gz",
Platform: platform.Platform{OS: "linux", Arch: "x64"},
Version: "10.1.60",
},
}

jsonStr, err := rm.buildArtifactsJSON(uploaded, results)
if err != nil {
t.Fatalf("buildArtifactsJSON() error = %v", err)
}

var artifacts storage.ReleaseArtifacts
if err := json.Unmarshal([]byte(jsonStr), &artifacts); err != nil {
t.Fatalf("buildArtifactsJSON() produced invalid JSON: %v", err)
}

var zipPlat, exePlat *storage.PlatformArtifact
for i := range artifacts.Platforms {
plat := &artifacts.Platforms[i]
if plat.Binary == nil {
continue
}
switch plat.Binary.Filename {
case "apache-tomcat-10.1.60-windows-x64.zip":
zipPlat = plat
case "apache-tomcat-10.1.60.exe":
exePlat = plat
}
}
if zipPlat == nil {
t.Fatal("windows zip missing from release artifacts")
}
if exePlat == nil {
t.Fatal("windows installer missing from release artifacts")
}
if zipPlat.Platform != "windows-x64" || exePlat.Platform != "windows-x64" {
t.Fatalf("platforms = %s and %s, want windows-x64", zipPlat.Platform, exePlat.Platform)
}
if zipPlat.Signature == nil || zipPlat.Signature.Filename != "apache-tomcat-10.1.60-windows-x64.zip.asc" {
t.Errorf("zip signature = %+v", zipPlat.Signature)
}
if exePlat.Signature == nil || exePlat.Signature.Filename != "apache-tomcat-10.1.60.exe.asc" {
t.Errorf("exe signature = %+v", exePlat.Signature)
}
if zipPlat.Audit == nil || exePlat.Audit == nil {
t.Error("zip and installer each need their own audit file")
}
}

func TestUploadArtifacts_DuplicateBasenameGetsPlatformQualifiedName(t *testing.T) {
tempDir := t.TempDir()
linuxDir := filepath.Join(tempDir, "linux-x64")
Expand Down
98 changes: 66 additions & 32 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -371,37 +372,6 @@ func (m *Manager) DownloadRuntime(ctx context.Context, runtimeName string, versi
return nil, fmt.Errorf("failed to get runtime provider: %w", err)
}

// Check database to skip already downloaded files
if m.db != nil {
var platformsToDownload []platform.Platform
for _, plat := range platforms {
alreadyDownloaded, err := m.db.IsAlreadyDownloaded(
runtimeName, version.LatestPatch, plat.OS, plat.Arch)
if err != nil {
m.stderr.Warn("failed to check download status in database",
"runtime", runtimeName, "version", version.LatestPatch,
"platform", plat.Classifier, "error", err)
platformsToDownload = append(platformsToDownload, plat)
continue
}

if alreadyDownloaded {
m.stdout.Info("skipping already downloaded and verified file",
"runtime", runtimeName, "version", version.LatestPatch,
"platform", plat.Classifier)
continue
}
platformsToDownload = append(platformsToDownload, plat)
}

if len(platformsToDownload) == 0 {
m.stdout.Info("all files already downloaded, skipping",
"runtime", runtimeName, "version", version.Version)
return []DownloadResult{}, nil
}
platforms = platformsToDownload
}

// Create download tasks
m.stdout.Debug("creating download tasks", "runtime", runtimeName, "version", version.Version)
tasks, err := provider.CreateDownloadTasks(version, platforms, outputDir)
Expand All @@ -413,6 +383,15 @@ func (m *Manager) DownloadRuntime(ctx context.Context, runtimeName string, versi
return nil, fmt.Errorf("failed to create download tasks: %w", err)
}

if m.db != nil {
tasks = m.withoutRecordedTasks(tasks, runtimeName, version.LatestPatch)
if len(tasks) == 0 {
m.stdout.Info("all files already downloaded, skipping",
"runtime", runtimeName, "version", version.Version)
return []DownloadResult{}, nil
}
}

m.stdout.Debug("download tasks created",
"runtime", runtimeName,
"version", version.Version,
Expand Down Expand Up @@ -668,6 +647,61 @@ func (m *Manager) DownloadRuntime(ctx context.Context, runtimeName string, versi
return results, nil
}

// withoutRecordedTasks drops main files that already have a successful
// database row, plus the checksum and signature tasks that belong to them.
// A platform can have more than one main file. Only the recorded filename is skipped.
func (m *Manager) withoutRecordedTasks(tasks []DownloadTask, runtimeName, version string) []DownloadTask {
recorded := make(map[string]struct{})
for _, task := range tasks {
if task.FileType != "main" {
continue
}
filename := filepath.Base(task.OutputPath)
done, err := m.db.IsFileDownloaded(runtimeName, version, task.Platform.OS, task.Platform.Arch, filename)
if err != nil {
m.stderr.Warn("failed to check download status in database",
"runtime", runtimeName,
"version", version,
"file", filename,
"error", err)
continue
}
if !done {
continue
}
recorded[task.OutputPath] = struct{}{}
m.stdout.Info("skipping already downloaded file",
"runtime", runtimeName,
"version", version,
"platform", fmt.Sprintf("%s-%s", task.Platform.OS, task.Platform.Arch),
"file", filename)
}
if len(recorded) == 0 {
return tasks
}

kept := make([]DownloadTask, 0, len(tasks))
for _, task := range tasks {
if taskIsRecorded(recorded, task.OutputPath) {
continue
}
kept = append(kept, task)
}
return kept
}

func taskIsRecorded(recorded map[string]struct{}, outputPath string) bool {
if _, ok := recorded[outputPath]; ok {
return true
}
for mainPath := range recorded {
if strings.HasPrefix(outputPath, mainPath+".") {
return true
}
}
return false
}

// verifyDownloads verifies downloaded files
func (m *Manager) verifyDownloads(ctx context.Context, results []DownloadResult, strategy VerificationStrategy, auditLogger *slog.Logger) {
// Inject audit logger into strategy if it has a Logger field
Expand Down Expand Up @@ -935,7 +969,7 @@ func (d *ConcurrentDownloader) downloadFile(ctx context.Context, task DownloadTa
result.FileSize = size
result.Size = size // Compatibility alias

d.stdout.Debug("file download completed",
d.stdout.Info("downloaded file",
"url", task.URL,
"output_path", task.OutputPath,
"size_bytes", size,
Expand Down
45 changes: 45 additions & 0 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -681,6 +682,50 @@ func TestManager_DownloadRuntime(t *testing.T) {
}
}

func TestManager_withoutRecordedTasks_KeepsUnrecordedFileOnSamePlatform(t *testing.T) {
db, err := storage.InitDB(storage.Config{DatabasePath: ":memory:", LogLevel: "silent"})
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
t.Cleanup(func() { _ = db.Close() })

const version = "10.1.60"
zip := &storage.Download{
Runtime: "tomcat",
Version: version,
Platform: "windows",
Architecture: "x64",
Filename: "apache-tomcat-10.1.60-windows-x64.zip",
SourceURL: "https://example.com/zip",
DownloadedAt: time.Now(),
VerificationStatus: "success",
}
if err := db.RecordDownload(zip); err != nil {
t.Fatalf("RecordDownload() error = %v", err)
}

manager := NewManager(NewRegistry(), db, slog.New(slog.NewJSONHandler(io.Discard, nil)), slog.New(slog.NewJSONHandler(io.Discard, nil)))
zipPath := filepath.Join(t.TempDir(), "windows-x64", zip.Filename)
exePath := filepath.Join(filepath.Dir(zipPath), "apache-tomcat-10.1.60.exe")
tasks := []DownloadTask{
{OutputPath: zipPath, FileType: "main", Platform: platform.Platform{OS: "windows", Arch: "x64"}},
{OutputPath: zipPath + ".sha512", FileType: "checksum", Platform: platform.Platform{OS: "windows", Arch: "x64"}},
{OutputPath: exePath, FileType: "main", Platform: platform.Platform{OS: "windows", Arch: "x64"}},
{OutputPath: exePath + ".sha512", FileType: "checksum", Platform: platform.Platform{OS: "windows", Arch: "x64"}},
{OutputPath: exePath + ".asc", FileType: "signature", Platform: platform.Platform{OS: "windows", Arch: "x64"}},
}

got := manager.withoutRecordedTasks(tasks, "tomcat", version)
if len(got) != 3 {
t.Fatalf("withoutRecordedTasks() len = %d, want 3", len(got))
}
for _, task := range got {
if filepath.Base(task.OutputPath) == zip.Filename || strings.HasPrefix(task.OutputPath, zipPath+".") {
t.Errorf("recorded zip task kept: %s", task.OutputPath)
}
}
}

func TestManager_DownloadRuntime_ErrorCases(t *testing.T) {
registry := NewRegistry()

Expand Down
Loading
Loading