diff --git a/packages/envd/internal/api/api.gen.go b/packages/envd/internal/api/api.gen.go index 512747b05f..a85920bd1a 100644 --- a/packages/envd/internal/api/api.gen.go +++ b/packages/envd/internal/api/api.gen.go @@ -23,6 +23,18 @@ const ( File EntryInfoType = "file" ) +// ComposeRequest defines model for ComposeRequest. +type ComposeRequest struct { + // Destination Destination file path for the composed file + Destination string `json:"destination"` + + // SourcePaths Ordered list of source file paths to concatenate + SourcePaths []string `json:"source_paths"` + + // Username User for setting ownership and resolving relative paths + Username *string `json:"username,omitempty"` +} + // EntryInfo defines model for EntryInfo. type EntryInfo struct { // Name Name of the file @@ -170,6 +182,9 @@ type PostInitJSONBody struct { // PostFilesMultipartRequestBody defines body for PostFiles for multipart/form-data ContentType. type PostFilesMultipartRequestBody PostFilesMultipartBody +// PostFilesComposeJSONRequestBody defines body for PostFilesCompose for application/json ContentType. +type PostFilesComposeJSONRequestBody = ComposeRequest + // PostInitJSONRequestBody defines body for PostInit for application/json ContentType. type PostInitJSONRequestBody PostInitJSONBody @@ -184,6 +199,9 @@ type ServerInterface interface { // Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. // (POST /files) PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) + // Compose multiple files into a single file using zero-copy concatenation. Source files are deleted after successful composition. + // (POST /files/compose) + PostFilesCompose(w http.ResponseWriter, r *http.Request) // Check the health of the service // (GET /health) GetHealth(w http.ResponseWriter, r *http.Request) @@ -217,6 +235,12 @@ func (_ Unimplemented) PostFiles(w http.ResponseWriter, r *http.Request, params w.WriteHeader(http.StatusNotImplemented) } +// Compose multiple files into a single file using zero-copy concatenation. Source files are deleted after successful composition. +// (POST /files/compose) +func (_ Unimplemented) PostFilesCompose(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Check the health of the service // (GET /health) func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) { @@ -378,6 +402,26 @@ func (siw *ServerInterfaceWrapper) PostFiles(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// PostFilesCompose operation middleware +func (siw *ServerInterfaceWrapper) PostFilesCompose(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostFilesCompose(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetHealth operation middleware func (siw *ServerInterfaceWrapper) GetHealth(w http.ResponseWriter, r *http.Request) { @@ -554,6 +598,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/files", wrapper.PostFiles) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/files/compose", wrapper.PostFilesCompose) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/health", wrapper.GetHealth) }) diff --git a/packages/envd/internal/api/compose.go b/packages/envd/internal/api/compose.go new file mode 100644 index 0000000000..738b847908 --- /dev/null +++ b/packages/envd/internal/api/compose.go @@ -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") + } +} diff --git a/packages/envd/internal/api/compose_test.go b/packages/envd/internal/api/compose_test.go new file mode 100644 index 0000000000..db1c8ca1f3 --- /dev/null +++ b/packages/envd/internal/api/compose_test.go @@ -0,0 +1,359 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/user" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/envd/internal/execcontext" + "github.com/e2b-dev/infra/packages/envd/internal/utils" +) + +func newComposeTestAPI(t *testing.T) (*API, *user.User) { + t.Helper() + + currentUser, err := user.Current() + require.NoError(t, err) + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + + return New(&logger, defaults, nil, false), currentUser +} + +func writeSourceFile(t *testing.T, dir string, name string, data []byte) string { + t.Helper() + + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, data, 0o644)) + + return path +} + +func callCompose(t *testing.T, api *API, req ComposeRequest) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(req) + require.NoError(t, err) + + httpReq := httptest.NewRequest(http.MethodPost, "/files/compose", bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + api.PostFilesCompose(w, httpReq) + + return w +} + +func TestCompose_ConcatenatesFiles(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + destPath := filepath.Join(t.TempDir(), "composed.txt") + + src0 := writeSourceFile(t, srcDir, "part0", []byte("Hello, ")) + src1 := writeSourceFile(t, srcDir, "part1", []byte("World")) + src2 := writeSourceFile(t, srcDir, "part2", []byte("!")) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src0, src1, src2}, + Destination: destPath, + Username: ¤tUser.Username, + }) + + resp := w.Result() + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result EntryInfo + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, destPath, result.Path) + assert.Equal(t, "composed.txt", result.Name) + assert.Equal(t, File, result.Type) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, []byte("Hello, World!"), data) +} + +func TestCompose_DeletesSourceFiles(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + destPath := filepath.Join(t.TempDir(), "composed.txt") + + src0 := writeSourceFile(t, srcDir, "part0", []byte("aaa")) + src1 := writeSourceFile(t, srcDir, "part1", []byte("bbb")) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src0, src1}, + Destination: destPath, + Username: ¤tUser.Username, + }) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + _, err := os.Stat(src0) + assert.True(t, os.IsNotExist(err), "source file 0 should be deleted after compose") + + _, err = os.Stat(src1) + assert.True(t, os.IsNotExist(err), "source file 1 should be deleted after compose") +} + +func TestCompose_RequiresSourcePaths(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{}, + Destination: "/tmp/dest.txt", + Username: ¤tUser.Username, + }) + + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestCompose_RequiresDestination(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{"/tmp/something"}, + Destination: "", + Username: ¤tUser.Username, + }) + + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestCompose_SourceEqualsDestination(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + src := writeSourceFile(t, srcDir, "file.txt", []byte("data")) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src}, + Destination: src, + Username: ¤tUser.Username, + }) + + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestCompose_SourceIsDirectory(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{t.TempDir()}, + Destination: filepath.Join(t.TempDir(), "dest.txt"), + Username: ¤tUser.Username, + }) + + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestCompose_SourceNotFound(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{"/tmp/nonexistent-file-12345"}, + Destination: filepath.Join(t.TempDir(), "dest.txt"), + Username: ¤tUser.Username, + }) + + assert.Equal(t, http.StatusNotFound, w.Result().StatusCode) +} + +func TestCompose_CreatesParentDirs(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + destPath := filepath.Join(t.TempDir(), "nested", "dir", "file.txt") + + src := writeSourceFile(t, srcDir, "part0", []byte("content")) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src}, + Destination: destPath, + Username: ¤tUser.Username, + }) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, []byte("content"), data) +} + +func TestCompose_LargeFile(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + destPath := filepath.Join(t.TempDir(), "large.bin") + + partSize := 1024 * 1024 + var sources []string + var expectedTotal int64 + + for i := range 10 { + data := bytes.Repeat([]byte{byte(i)}, partSize) + src := writeSourceFile(t, srcDir, filepath.Base(t.TempDir())+string(rune('0'+i)), data) + sources = append(sources, src) + expectedTotal += int64(partSize) + } + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: sources, + Destination: destPath, + Username: ¤tUser.Username, + }) + + resp := w.Result() + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result EntryInfo + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, destPath, result.Path) + assert.Equal(t, File, result.Type) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Len(t, data, int(expectedTotal)) + + for i := range 10 { + offset := i * partSize + assert.Equal(t, byte(i), data[offset], "first byte of part %d should match", i) + assert.Equal(t, byte(i), data[offset+partSize-1], "last byte of part %d should match", i) + } +} + +func TestCompose_RoundTripWithDownload(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + destPath := filepath.Join(t.TempDir(), "roundtrip.txt") + + src0 := writeSourceFile(t, srcDir, "part0", []byte("round")) + src1 := writeSourceFile(t, srcDir, "part1", []byte("trip")) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src0, src1}, + Destination: destPath, + Username: ¤tUser.Username, + }) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + downloadReq := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(destPath), nil) + downloadW := httptest.NewRecorder() + api.GetFiles(downloadW, downloadReq, GetFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + }) + + downloadResp := downloadW.Result() + defer downloadResp.Body.Close() + + require.Equal(t, http.StatusOK, downloadResp.StatusCode) + + body, err := io.ReadAll(downloadResp.Body) + require.NoError(t, err) + assert.Equal(t, []byte("roundtrip"), body) +} + +func TestCompose_PreservesExistingFileOnFailure(t *testing.T) { + t.Parallel() + + if os.Getuid() == 0 { + t.Skip("test requires non-root to enforce directory permissions") + } + + api, currentUser := newComposeTestAPI(t) + destDir := t.TempDir() + destPath := filepath.Join(destDir, "existing.txt") + + require.NoError(t, os.WriteFile(destPath, []byte("original content"), 0o644)) + + srcDir := t.TempDir() + src := writeSourceFile(t, srcDir, "part0", []byte("new content")) + + // Make destination directory read-only so temp file creation fails. + require.NoError(t, os.Chmod(destDir, 0o500)) + defer os.Chmod(destDir, 0o755) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src}, + Destination: destPath, + Username: ¤tUser.Username, + }) + assert.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) + + // Restore permissions and verify original file is intact. + require.NoError(t, os.Chmod(destDir, 0o755)) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, []byte("original content"), data) + + // Source file should still exist since compose failed. + _, err = os.Stat(src) + assert.NoError(t, err, "source file should be preserved when compose fails") +} + +func TestCompose_SingleFile(t *testing.T) { + t.Parallel() + + api, currentUser := newComposeTestAPI(t) + srcDir := t.TempDir() + destPath := filepath.Join(t.TempDir(), "single.txt") + + src := writeSourceFile(t, srcDir, "only", []byte("solo")) + + w := callCompose(t, api, ComposeRequest{ + SourcePaths: []string{src}, + Destination: destPath, + Username: ¤tUser.Username, + }) + + resp := w.Result() + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result EntryInfo + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, destPath, result.Path) + assert.Equal(t, "single.txt", result.Name) + assert.Equal(t, File, result.Type) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, []byte("solo"), data) +} diff --git a/packages/envd/main.go b/packages/envd/main.go index f2dfed7c46..0249f1201f 100644 --- a/packages/envd/main.go +++ b/packages/envd/main.go @@ -47,7 +47,7 @@ const ( ) var ( - Version = "0.5.4" + Version = "0.5.5" commitSHA string diff --git a/packages/envd/spec/envd.yaml b/packages/envd/spec/envd.yaml index 83091f1ab5..562769189f 100644 --- a/packages/envd/spec/envd.yaml +++ b/packages/envd/spec/envd.yaml @@ -130,6 +130,37 @@ paths: "507": $ref: "#/components/responses/NotEnoughDiskSpace" + /files/compose: + post: + summary: Compose multiple files into a single file using zero-copy concatenation. Source files are deleted after successful composition. + tags: [files] + security: + - AccessTokenAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ComposeRequest" + responses: + "200": + description: Files composed successfully + content: + application/json: + schema: + $ref: "#/components/schemas/EntryInfo" + "400": + $ref: "#/components/responses/InvalidPath" + "401": + $ref: "#/components/responses/InvalidUser" + "404": + $ref: "#/components/responses/FileNotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "507": + $ref: "#/components/responses/NotEnoughDiskSpace" + components: securitySchemes: AccessTokenAuth: @@ -289,6 +320,23 @@ components: disk_total: type: integer description: Total disk space in bytes + ComposeRequest: + type: object + required: + - source_paths + - destination + properties: + source_paths: + type: array + items: + type: string + description: Ordered list of source file paths to concatenate + destination: + type: string + description: Destination file path for the composed file + username: + type: string + description: User for setting ownership and resolving relative paths VolumeMount: type: object description: Volume diff --git a/packages/orchestrator/internal/sandbox/envd/envd.gen.go b/packages/orchestrator/internal/sandbox/envd/envd.gen.go index 73d03e999b..20001dd70d 100644 --- a/packages/orchestrator/internal/sandbox/envd/envd.gen.go +++ b/packages/orchestrator/internal/sandbox/envd/envd.gen.go @@ -18,6 +18,18 @@ const ( File EntryInfoType = "file" ) +// ComposeRequest defines model for ComposeRequest. +type ComposeRequest struct { + // Destination Destination file path for the composed file + Destination string `json:"destination"` + + // SourcePaths Ordered list of source file paths to concatenate + SourcePaths []string `json:"source_paths"` + + // Username User for setting ownership and resolving relative paths + Username string `json:"username,omitempty"` +} + // EntryInfo defines model for EntryInfo. type EntryInfo struct { // Name Name of the file @@ -165,5 +177,8 @@ type PostInitJSONBody struct { // PostFilesMultipartRequestBody defines body for PostFiles for multipart/form-data ContentType. type PostFilesMultipartRequestBody PostFilesMultipartBody +// PostFilesComposeJSONRequestBody defines body for PostFilesCompose for application/json ContentType. +type PostFilesComposeJSONRequestBody = ComposeRequest + // PostInitJSONRequestBody defines body for PostInit for application/json ContentType. type PostInitJSONRequestBody PostInitJSONBody diff --git a/tests/integration/internal/envd/generated.go b/tests/integration/internal/envd/generated.go index c0461908a3..53d65a3061 100644 --- a/tests/integration/internal/envd/generated.go +++ b/tests/integration/internal/envd/generated.go @@ -27,6 +27,18 @@ const ( File EntryInfoType = "file" ) +// ComposeRequest defines model for ComposeRequest. +type ComposeRequest struct { + // Destination Destination file path for the composed file + Destination string `json:"destination"` + + // SourcePaths Ordered list of source file paths to concatenate + SourcePaths []string `json:"source_paths"` + + // Username User for setting ownership and resolving relative paths + Username *string `json:"username,omitempty"` +} + // EntryInfo defines model for EntryInfo. type EntryInfo struct { // Name Name of the file @@ -174,6 +186,9 @@ type PostInitJSONBody struct { // PostFilesMultipartRequestBody defines body for PostFiles for multipart/form-data ContentType. type PostFilesMultipartRequestBody PostFilesMultipartBody +// PostFilesComposeJSONRequestBody defines body for PostFilesCompose for application/json ContentType. +type PostFilesComposeJSONRequestBody = ComposeRequest + // PostInitJSONRequestBody defines body for PostInit for application/json ContentType. type PostInitJSONRequestBody PostInitJSONBody @@ -259,6 +274,11 @@ type ClientInterface interface { // PostFilesWithBody request with any body PostFilesWithBody(ctx context.Context, params *PostFilesParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostFilesComposeWithBody request with any body + PostFilesComposeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostFilesCompose(ctx context.Context, body PostFilesComposeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetHealth request GetHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -307,6 +327,30 @@ func (c *Client) PostFilesWithBody(ctx context.Context, params *PostFilesParams, return c.Client.Do(req) } +func (c *Client) PostFilesComposeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostFilesComposeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostFilesCompose(ctx context.Context, body PostFilesComposeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostFilesComposeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetHealthRequest(c.Server) if err != nil { @@ -578,6 +622,46 @@ func NewPostFilesRequestWithBody(server string, params *PostFilesParams, content return req, nil } +// NewPostFilesComposeRequest calls the generic PostFilesCompose builder with application/json body +func NewPostFilesComposeRequest(server string, body PostFilesComposeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostFilesComposeRequestWithBody(server, "application/json", bodyReader) +} + +// NewPostFilesComposeRequestWithBody generates requests for PostFilesCompose with any type of body +func NewPostFilesComposeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/files/compose") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetHealthRequest generates requests for GetHealth func NewGetHealthRequest(server string) (*http.Request, error) { var err error @@ -724,6 +808,11 @@ type ClientWithResponsesInterface interface { // PostFilesWithBodyWithResponse request with any body PostFilesWithBodyWithResponse(ctx context.Context, params *PostFilesParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFilesResponse, error) + // PostFilesComposeWithBodyWithResponse request with any body + PostFilesComposeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFilesComposeResponse, error) + + PostFilesComposeWithResponse(ctx context.Context, body PostFilesComposeJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFilesComposeResponse, error) + // GetHealthWithResponse request GetHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthResponse, error) @@ -809,6 +898,33 @@ func (r PostFilesResponse) StatusCode() int { return 0 } +type PostFilesComposeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EntryInfo + JSON400 *InvalidPath + JSON401 *InvalidUser + JSON404 *FileNotFound + JSON500 *InternalServerError + JSON507 *NotEnoughDiskSpace +} + +// Status returns HTTPResponse.Status +func (r PostFilesComposeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostFilesComposeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetHealthResponse struct { Body []byte HTTPResponse *http.Response @@ -900,6 +1016,23 @@ func (c *ClientWithResponses) PostFilesWithBodyWithResponse(ctx context.Context, return ParsePostFilesResponse(rsp) } +// PostFilesComposeWithBodyWithResponse request with arbitrary body returning *PostFilesComposeResponse +func (c *ClientWithResponses) PostFilesComposeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFilesComposeResponse, error) { + rsp, err := c.PostFilesComposeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostFilesComposeResponse(rsp) +} + +func (c *ClientWithResponses) PostFilesComposeWithResponse(ctx context.Context, body PostFilesComposeJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFilesComposeResponse, error) { + rsp, err := c.PostFilesCompose(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostFilesComposeResponse(rsp) +} + // GetHealthWithResponse request returning *GetHealthResponse func (c *ClientWithResponses) GetHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthResponse, error) { rsp, err := c.GetHealth(ctx, reqEditors...) @@ -1062,6 +1195,67 @@ func ParsePostFilesResponse(rsp *http.Response) (*PostFilesResponse, error) { return response, nil } +// ParsePostFilesComposeResponse parses an HTTP response from a PostFilesComposeWithResponse call +func ParsePostFilesComposeResponse(rsp *http.Response) (*PostFilesComposeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostFilesComposeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EntryInfo + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidPath + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest InvalidUser + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest FileNotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest NotEnoughDiskSpace + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON507 = &dest + + } + + return response, nil +} + // ParseGetHealthResponse parses an HTTP response from a GetHealthWithResponse call func ParseGetHealthResponse(rsp *http.Response) (*GetHealthResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body)