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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to this project will be documented in this file.

## [v2.2.1] - 2026-04-14

### Features

- **Multi-Method Support**: Functions now support `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` methods for invocations. The request method is passed to the function handler via `req.method`.
- **Automatic Pool Cleanup**: Warm container pools are now immediately drained and containers are stopped when a function is deleted.

### Bug Fixes & Improvements

- **Resource Management**: Fixed a temporary file leak in the deployment process where zip files were not cleaned up from `/tmp`.
- **Handler Robustness**: Improved the standard test function to handle null headers and gracefully echo request metadata.
- **CI/CD**: Added integration test steps to the GitHub Actions workflow to ensure multi-method and pool management stability.
- **API Reliability**: Corrected response status handling and updated internal versioning variables.

---

## [v2.2.0] - 2026-04-13

### Features
Expand Down
3 changes: 3 additions & 0 deletions internal/api/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func deleteFuncHandler(c *gin.Context) {
// We don't return here because files and metadata are already gone
}

// Clean up container pool
config.PoolManager.DeletePool(c, config.DockerClient, name)

c.JSON(http.StatusOK, gin.H{
"deleted": name,
})
Expand Down
23 changes: 15 additions & 8 deletions internal/api/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import (
)

func registerInvokeRoutes(router *gin.Engine) {
router.POST("/invoke/:name", invokeHandler)
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
router.Handle(method, "/invoke/:name", invokeHandler)
}
}

func invokeHandler(c *gin.Context) {
Expand Down Expand Up @@ -45,17 +47,22 @@ func invokeHandler(c *gin.Context) {
}

// Read request body
bodyBytes, err := c.GetRawData()
if err != nil {
log.Println("ERROR: " + err.Error())
c.JSON(http.StatusBadRequest, gin.H{
"error": "Failed to read request",
})
return
var bodyBytes []byte
if c.Request.Body != nil {
var err error
bodyBytes, err = c.GetRawData()
if err != nil {
log.Println("ERROR: " + err.Error())
c.JSON(http.StatusBadRequest, gin.H{
"error": "Failed to read request",
})
return
}
}

// Build request object
req := functions.InvokeRequest{
Method: c.Request.Method,
Headers: headers,
Body: string(bodyBytes),
}
Expand Down
1 change: 1 addition & 0 deletions internal/functions/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
var ErrRateLimited = errors.New("rate limit exceeded")

type InvokeRequest struct {
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
Expand Down
48 changes: 48 additions & 0 deletions internal/functions/invoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func TestInvoke_HappyPath(t *testing.T) {
}

req := functions.InvokeRequest{
Method: "POST",
Headers: map[string]string{
"Content-Type": "application/json",
},
Expand Down Expand Up @@ -94,4 +95,51 @@ func TestInvoke_HappyPath(t *testing.T) {
if _, ok := body["json"]; !ok {
t.Fatalf("expected json field in response body")
}

if body["method"] != "POST" {
t.Errorf("expected default method POST, got %s", body["method"])
}
}

func TestInvoke_Methods(t *testing.T) {
cleanup := setupInvokeEnv(t)
defer cleanup()

funcName := "methods-func"
if err := functions.Deploy(validZipFile, funcName, 0); err != nil {
t.Fatalf("deploy failed: %v", err)
}

d := &docker.Docker{
WorkerPath: config.WorkerPath,
}
defer d.Close()
ctx := t.Context()
defer config.PoolManager.DeleteAllContainers(ctx, d)

methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
req := functions.InvokeRequest{
Method: method,
}
res, err := functions.Invoke(ctx, d, funcName, req)
if err != nil {
t.Fatalf("invoke failed for %s: %v", method, err)
}

if res.StatusCode != 200 {
t.Fatalf("expected status 200, got %d", res.StatusCode)
}

var body map[string]any
if err := json.Unmarshal(res.Body, &body); err != nil {
t.Fatalf("invalid response body: %v", err)
}

if body["method"] != method {
t.Errorf("expected method %s, got %s", method, body["method"])
}
})
}
}
20 changes: 18 additions & 2 deletions internal/pool/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,24 @@ func parseRateLimit(rateLimit int) (rate.Limit, int) {
return rate.Limit(rateLimit), burst
}

func (pm *PoolManager) Delete(funcName string) {
pm.pools.Delete(funcName)
func (pm *PoolManager) DeletePool(ctx context.Context, d *docker.Docker, funcName string) {
if val, ok := pm.pools.Load(funcName); ok {
p := val.(*ContainerPool)
// Drain the pool and remove containers
for {
select {
case e := <-p.Idle:
err := d.ContainerRemove(ctx, e.ContainerID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete container (%s) on pool deletion: %s\n", e.ContainerID[:12], err)
}
os.RemoveAll(e.SocketPath)
default:
pm.pools.Delete(funcName)
return
}
}
}
}

func (pm *PoolManager) DeleteAllContainers(ctx context.Context, d *docker.Docker) {
Expand Down
3 changes: 2 additions & 1 deletion internal/pool/manager_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pool

import (
"context"
"testing"
"golang.org/x/time/rate"
)
Expand All @@ -26,7 +27,7 @@ func TestPoolManager_GetOrCreate(t *testing.T) {
}

// Test Delete
pm.Delete("func-1")
pm.DeletePool(context.Background(), nil, "func-1")

// Should create a new one now since the old was deleted
p4 := pm.GetOrCreate("func-1", 0, 1)
Expand Down
Binary file modified test_data/zip/valid.zip
Binary file not shown.
Loading