Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bdd31f3
feat(envd): Add multi-part upload API endpoints
mishushakov Mar 2, 2026
6d532d0
chore: auto-commit generated changes
github-actions[bot] Mar 2, 2026
2e24715
fix(envd): fix multipart upload race condition, sort order, and atomi…
mishushakov Mar 2, 2026
5e50a05
chore: auto-commit generated changes
github-actions[bot] Mar 2, 2026
dbce132
fix(envd): fix lint errors in multipart upload handlers
mishushakov Mar 2, 2026
d4cd3f4
fix(envd): fix TOCTOU race in multipart upload PUT vs Complete/Delete
mishushakov Mar 3, 2026
33297f9
fix(envd): remove truncated part file on write failure
mishushakov Mar 3, 2026
491abc0
fix(envd): include uploadId in tmpPath to avoid collision
mishushakov Mar 3, 2026
98ef413
fix(envd): return 500 on abort cleanup failure instead of silent 204
mishushakov Mar 3, 2026
d20534a
fix(envd): fix temp file assertion to use actual path with uploadId
mishushakov Mar 3, 2026
bf77c08
fix(envd): re-register upload session on Complete failure to allow retry
mishushakov Mar 3, 2026
1541da9
renamed meta to session
mishushakov Mar 3, 2026
eb1ccc5
replaced multi-part implementation with composite upload
mishushakov Mar 3, 2026
f5b2c9c
chore: auto-commit generated changes
github-actions[bot] Mar 3, 2026
85d70b1
fix(envd): skip compose preservation test when running as root
mishushakov Mar 3, 2026
3902310
fix(envd): reject compose when source path equals destination
mishushakov Mar 3, 2026
44ea33d
fix(envd): validate source paths are regular files in compose
mishushakov Mar 3, 2026
6000709
feat(envd): return EntryInfo from compose endpoint
mishushakov Mar 3, 2026
7527f34
chore: auto-commit generated changes
github-actions[bot] Mar 3, 2026
d9a9d93
chore(envd): rename multipart files to compose and bump version to 0.5.5
mishushakov Mar 12, 2026
ed73d5b
Merge branch 'main' into mishushakov/riyadh-region
mishushakov Mar 12, 2026
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
47 changes: 47 additions & 0 deletions packages/envd/internal/api/api.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

214 changes: 214 additions & 0 deletions packages/envd/internal/api/multipart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package api

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/user"
"path/filepath"
"syscall"

"github.com/google/uuid"

"github.com/e2b-dev/infra/packages/envd/internal/execcontext"
"github.com/e2b-dev/infra/packages/envd/internal/logs"
"github.com/e2b-dev/infra/packages/envd/internal/permissions"
)

func (a *API) PostFilesCompose(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()

operationID := logs.AssignOperationID()

var req ComposeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))

return
}

if len(req.SourcePaths) == 0 {
jsonError(w, http.StatusBadRequest, fmt.Errorf("source_paths must not be empty"))

return
}

if req.Destination == "" {
jsonError(w, http.StatusBadRequest, fmt.Errorf("destination is required"))

return
}

username, err := execcontext.ResolveDefaultUsername(req.Username, a.defaults.User)
if err != nil {
a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("no user specified")
jsonError(w, http.StatusBadRequest, err)

return
}

u, err := user.Lookup(username)
if err != nil {
errMsg := fmt.Errorf("error looking up user '%s': %w", username, err)
a.logger.Error().Err(errMsg).Str(string(logs.OperationIDKey), operationID).Msg("user lookup failed")
jsonError(w, http.StatusUnauthorized, errMsg)

return
}

uid, gid, err := permissions.GetUserIdInts(u)
if err != nil {
errMsg := fmt.Errorf("error getting user ids: %w", err)
a.logger.Error().Err(errMsg).Str(string(logs.OperationIDKey), operationID).Msg("failed to get user ids")
jsonError(w, http.StatusInternalServerError, errMsg)

return
}

destPath, err := permissions.ExpandAndResolve(req.Destination, u, a.defaults.Workdir)
if err != nil {
errMsg := fmt.Errorf("error resolving destination path: %w", err)
a.logger.Error().Err(errMsg).Str(string(logs.OperationIDKey), operationID).Msg("path resolution failed")
jsonError(w, http.StatusBadRequest, errMsg)

return
}

resolvedSources := make([]string, len(req.SourcePaths))
for i, src := range req.SourcePaths {
resolved, err := permissions.ExpandAndResolve(src, u, a.defaults.Workdir)
if err != nil {
jsonError(w, http.StatusBadRequest, fmt.Errorf("error resolving source path %q: %w", src, err))

return
}

if resolved == destPath {
jsonError(w, http.StatusBadRequest, fmt.Errorf("source path %q cannot be the same as destination", src))

return
}

info, err := os.Stat(resolved)
if err != nil {
jsonError(w, http.StatusNotFound, fmt.Errorf("source file not found: %s", src))

return
}

if !info.Mode().IsRegular() {
jsonError(w, http.StatusBadRequest, fmt.Errorf("source path is not a regular file: %s", src))

return
}

resolvedSources[i] = resolved
}

err = permissions.EnsureDirs(filepath.Dir(destPath), uid, gid)
if err != nil {
jsonError(w, http.StatusInternalServerError, fmt.Errorf("error ensuring directories: %w", err))

return
}

// Write to a temporary file and rename on success to avoid destroying
// any pre-existing file at destPath if assembly fails midway.
tmpPath := destPath + ".e2b-compose." + uuid.New().String() + ".tmp"

destFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666)
if err != nil {
if errors.Is(err, syscall.ENOSPC) {
jsonError(w, http.StatusInsufficientStorage, fmt.Errorf("not enough disk space available"))

return
}

jsonError(w, http.StatusInternalServerError, fmt.Errorf("error creating destination file: %w", err))

return
}

err = os.Chown(tmpPath, uid, gid)
if err != nil {
destFile.Close()
os.Remove(tmpPath)
jsonError(w, http.StatusInternalServerError, fmt.Errorf("error changing file ownership: %w", err))

return
}

var totalSize int64

for _, srcPath := range resolvedSources {
srcFile, err := os.Open(srcPath)
if err != nil {
destFile.Close()
os.Remove(tmpPath)
jsonError(w, http.StatusInternalServerError, fmt.Errorf("error opening source file %s: %w", srcPath, err))

return
}

// ReadFrom uses copy_file_range on Linux for zero-copy transfers
// between regular files — data moves kernel-side without touching
// userspace buffers.
n, err := destFile.ReadFrom(srcFile)
srcFile.Close()

if err != nil {
destFile.Close()
os.Remove(tmpPath)

if errors.Is(err, syscall.ENOSPC) {
jsonError(w, http.StatusInsufficientStorage, fmt.Errorf("not enough disk space available"))

return
}

jsonError(w, http.StatusInternalServerError, fmt.Errorf("error composing source %s: %w", srcPath, err))

return
}

totalSize += n
}

if err := destFile.Close(); err != nil {
os.Remove(tmpPath)
jsonError(w, http.StatusInternalServerError, fmt.Errorf("error closing destination file: %w", err))

return
}

if err := os.Rename(tmpPath, destPath); err != nil {
os.Remove(tmpPath)
jsonError(w, http.StatusInternalServerError, fmt.Errorf("error finalizing compose: %w", err))

return
}

for _, srcPath := range resolvedSources {
os.Remove(srcPath)
}

a.logger.Info().
Str(string(logs.OperationIDKey), operationID).
Str("path", destPath).
Int("sources", len(resolvedSources)).
Int64("size", totalSize).
Msg("File compose completed")

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

if err := json.NewEncoder(w).Encode(EntryInfo{
Path: destPath,
Name: filepath.Base(destPath),
Type: File,
}); err != nil {
a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("failed to encode compose response")
}
}
Loading
Loading