diff --git a/cli/deploy.go b/cli/deploy.go index 11b8cd7e..979914f3 100644 --- a/cli/deploy.go +++ b/cli/deploy.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -304,6 +305,12 @@ all projects in a monorepo (looks for blaxel.toml in subdirectories).`, return } + if err = deployment.ValidateArchiveSize(); err != nil { + err = fmt.Errorf("error validating blaxel deployment: %w", err) + core.PrintError("Deploy", err) + core.ExitWithError(err) + } + startTime := time.Now() if !noTTY { @@ -367,6 +374,37 @@ type Deployment struct { skipBuild bool } +const maxArchiveUploadSize = 5 * 1024 * 1024 * 1024 + +var errArchiveTooLarge = errors.New("archive size exceeds the 5 GB upload limit") + +func (d *Deployment) ValidateArchiveSize() error { + if d.archive == nil { + return nil + } + fileInfo, err := os.Stat(d.archive.Name()) + if err != nil { + return fmt.Errorf("failed to get archive file info: %w", err) + } + return archiveSizeError(fileInfo.Size(), core.IsVolumeTemplate(core.GetConfig().Type)) +} + +func archiveSizeError(size int64, volumeTemplate bool) error { + if size <= maxArchiveUploadSize { + return nil + } + if volumeTemplate { + return core.MarkExpectedError( + fmt.Errorf("%w; reduce the files in the volume template directory (.blaxelignore is not used for volume templates)", errArchiveTooLarge), + core.CLIErrorValidation, + ) + } + return core.MarkExpectedError( + fmt.Errorf("%w; reduce the archive size by adding files or directories to .blaxelignore", errArchiveTooLarge), + core.CLIErrorValidation, + ) +} + func (d *Deployment) Generate(skipBuild bool) error { if d.name == "" { d.name = filepath.Base(filepath.Join(d.cwd, d.folder)) @@ -1115,6 +1153,34 @@ func (d *Deployment) runInteractiveDeployment(resources []*deploy.Resource, addi } }() + if core.IsVolumeTemplate(core.GetConfig().Type) { + model.UpdateResource(0, deploy.StatusCompressing, "Compressing files", nil) + model.AddBuildLog(0, "Starting compression of volume template files...") + + var lastLoggedProgress int + d.progressCallback = func(status string, progress int) { + model.UpdateResource(0, deploy.StatusCompressing, status, nil) + if progress > 0 && progress%10 == 0 && progress != lastLoggedProgress { + model.AddBuildLog(0, fmt.Sprintf("Compression progress: %d%%", progress)) + lastLoggedProgress = progress + } + } + + if err := d.Tar(); err != nil { + model.UpdateResource(0, deploy.StatusFailed, "Compression failed", err) + model.AddBuildLog(0, fmt.Sprintf("Failed to compress files: %v", err)) + model.Complete() + return + } + if err := d.ValidateArchiveSize(); err != nil { + model.UpdateResource(0, deploy.StatusFailed, "Archive too large", err) + model.AddBuildLog(0, err.Error()) + model.Complete() + return + } + model.AddBuildLog(0, "Compression completed (100%)") + } + // Determine where main resources end and additional resources begin mainResourceCount := len(resources) - len(additionalResources) @@ -1163,32 +1229,6 @@ func (d *Deployment) runInteractiveDeployment(resources []*deploy.Resource, addi func (d *Deployment) deployResourceInteractive(resource *deploy.Resource, model *deploy.InteractiveModel, idx int, deployment core.Result) { config := core.GetConfig() - // For volume templates, handle compression first - if core.IsVolumeTemplate(config.Type) { - model.UpdateResource(idx, deploy.StatusCompressing, "Compressing files", nil) - model.AddBuildLog(idx, "Starting compression of volume template files...") - - // Set up progress callback for compression - var lastLoggedProgress int - d.progressCallback = func(status string, progress int) { - model.UpdateResource(idx, deploy.StatusCompressing, status, nil) - // Log every 10% to avoid log spam - if progress > 0 && progress%10 == 0 && progress != lastLoggedProgress { - model.AddBuildLog(idx, fmt.Sprintf("Compression progress: %d%%", progress)) - lastLoggedProgress = progress - } - } - - // Create the tar archive - err := d.Tar() - if err != nil { - model.UpdateResource(idx, deploy.StatusFailed, "Compression failed", err) - model.AddBuildLog(idx, fmt.Sprintf("Failed to compress files: %v", err)) - return - } - model.AddBuildLog(idx, "Compression completed (100%)") - } - // Start deployment model.UpdateResource(idx, deploy.StatusDeploying, "Applying resource", nil) model.AddBuildLog(idx, fmt.Sprintf("Starting deployment of %s/%s", resource.Kind, resource.Name)) @@ -1975,6 +2015,9 @@ func (d *Deployment) UploadWithRetry(url string, refreshURL func() (string, erro if lastErr == nil { return nil } + if errors.Is(lastErr, errArchiveTooLarge) { + return lastErr + } } return lastErr } @@ -1992,6 +2035,9 @@ func (d *Deployment) Upload(url string) error { if err != nil { return fmt.Errorf("failed to get file info: %w", err) } + if err := archiveSizeError(fileInfo.Size(), core.IsVolumeTemplate(core.GetConfig().Type)); err != nil { + return err + } // Wrap the file reader with progress tracking var reader io.Reader = archiveFile @@ -2312,6 +2358,43 @@ func (d *Deployment) Zip() error { } func (d *Deployment) Tar() error { + config := core.GetConfig() + volumeDir := config.Directory + if volumeDir == "" { + volumeDir = "." + } + archiveRoot := filepath.Join(d.cwd, volumeDir) + if _, err := os.Stat(archiveRoot); err != nil { + if os.IsNotExist(err) { + return core.MarkExpectedError( + fmt.Errorf("volume template directory does not exist: %s", volumeDir), + core.CLIErrorNotFound, + ) + } + return fmt.Errorf("failed to inspect volume template directory %q: %w", volumeDir, err) + } + + var size int64 + err := filepath.WalkDir(archiveRoot, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type().IsRegular() && filepath.Base(path) != "blaxel.toml" { + info, err := entry.Info() + if err != nil { + return err + } + if info.Size() > maxArchiveUploadSize-size { + return archiveSizeError(maxArchiveUploadSize+1, true) + } + size += info.Size() + } + return nil + }) + if err != nil { + return err + } + tarFile, err := os.CreateTemp("", ".blaxel.tar") if err != nil { return fmt.Errorf("failed to create temp file: %w", err) diff --git a/cli/deploy_test.go b/cli/deploy_test.go index b0ced889..1a7895f8 100644 --- a/cli/deploy_test.go +++ b/cli/deploy_test.go @@ -3,6 +3,8 @@ package cli import ( "archive/tar" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -560,6 +562,30 @@ directory = "app" assert.Contains(t, archivedFiles, "index.html") } +func TestVolumeTemplateTarRejectsOversizedFilesBeforeCreatingArchive(t *testing.T) { + tempDir := t.TempDir() + archiveDir := t.TempDir() + t.Setenv("TMPDIR", archiveDir) + file, err := os.Create(filepath.Join(tempDir, "oversized.bin")) + require.NoError(t, err) + require.NoError(t, file.Truncate(5*1024*1024*1024+1)) + require.NoError(t, file.Close()) + + core.ResetConfig() + core.SetConfigType("volumetemplate") + t.Cleanup(core.ResetConfig) + d := Deployment{cwd: tempDir} + + err = d.Tar() + + require.EqualError(t, err, "archive size exceeds the 5 GB upload limit; reduce the files in the volume template directory (.blaxelignore is not used for volume templates)") + assert.ErrorIs(t, err, errArchiveTooLarge) + assert.Nil(t, d.archive) + archives, err := filepath.Glob(filepath.Join(archiveDir, ".blaxel.tar*")) + require.NoError(t, err) + assert.Empty(t, archives) +} + func TestDeploymentReadBlaxelToml(t *testing.T) { // Create a temp directory with blaxel.toml tempDir, err := os.MkdirTemp("", "deploy_test") @@ -661,6 +687,64 @@ func TestProgressReaderCallback(t *testing.T) { assert.Equal(t, int64(100), lastTotalBytes) } +func TestUploadWithRetryRejectsOversizedArchiveWithoutRetrying(t *testing.T) { + archive, err := os.CreateTemp(t.TempDir(), "archive-*.zip") + require.NoError(t, err) + require.NoError(t, archive.Truncate(5*1024*1024*1024+1)) + require.NoError(t, archive.Close()) + + d := Deployment{archive: archive} + refreshes := 0 + err = d.UploadWithRetry("http://localhost", func() (string, error) { + refreshes++ + return "http://localhost", nil + }) + + require.EqualError(t, err, "archive size exceeds the 5 GB upload limit; reduce the archive size by adding files or directories to .blaxelignore") + assert.Zero(t, refreshes) +} + +func TestValidateArchiveSizeGuidance(t *testing.T) { + archive, err := os.CreateTemp(t.TempDir(), "archive-*.tar") + require.NoError(t, err) + require.NoError(t, archive.Truncate(5*1024*1024*1024+1)) + require.NoError(t, archive.Close()) + d := Deployment{archive: archive} + + t.Run("source code", func(t *testing.T) { + core.SetConfigType("sandbox") + t.Cleanup(core.ResetConfig) + err := d.ValidateArchiveSize() + require.EqualError(t, err, "archive size exceeds the 5 GB upload limit; reduce the archive size by adding files or directories to .blaxelignore") + assert.ErrorIs(t, err, errArchiveTooLarge) + }) + + t.Run("volume template", func(t *testing.T) { + core.SetConfigType("volumetemplate") + t.Cleanup(core.ResetConfig) + err := d.ValidateArchiveSize() + require.EqualError(t, err, "archive size exceeds the 5 GB upload limit; reduce the files in the volume template directory (.blaxelignore is not used for volume templates)") + assert.ErrorIs(t, err, errArchiveTooLarge) + }) +} + +func TestUploadAllowsArchiveAtSizeLimit(t *testing.T) { + archive, err := os.CreateTemp(t.TempDir(), "archive-*.zip") + require.NoError(t, err) + require.NoError(t, archive.Truncate(5*1024*1024*1024)) + require.NoError(t, archive.Close()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "stop", http.StatusBadRequest) + })) + defer server.Close() + + d := Deployment{archive: archive} + err = d.Upload(server.URL) + + require.EqualError(t, err, "upload failed with status: 400 Bad Request") +} + func TestDeploymentWithJobConfig(t *testing.T) { // Create a temp directory with blaxel.toml for job tempDir, err := os.MkdirTemp("", "deploy_test") diff --git a/cli/push.go b/cli/push.go index 474de755..35929e23 100644 --- a/cli/push.go +++ b/cli/push.go @@ -334,6 +334,10 @@ For private registries, supply credentials via --registry-cred or --docker-confi core.PrintError("Push", fmt.Errorf("failed to package source code: %w", err)) core.ExitWithError(err) } + if err = deployment.ValidateArchiveSize(); err != nil { + core.PrintError("Push", err) + core.ExitWithError(err) + } // Call POST /images to get the presigned URL fmt.Println("Requesting image build...")