feat: add secrets and triggers management to function commands

- Introduced `secrets` command for managing function secrets, including set, list, and delete operations.
- Added `triggers` command for managing PubSub triggers associated with functions, allowing addition, listing, and deletion of triggers.
- Implemented API handlers for secrets management, including setting, listing, and deleting secrets.
- Updated serverless handlers to support new secrets and triggers functionalities.
- Enhanced tests for the new features, ensuring proper functionality and error handling.
This commit is contained in:
anonpenguin23 2026-02-23 19:18:39 +02:00
parent 2fecebc0c2
commit 72fb5f1a5a
13 changed files with 1226 additions and 16 deletions

View File

@ -159,6 +159,8 @@ orama env use <name> # Switch environment
Orama supports high-performance serverless function execution using WebAssembly (WASM). Functions are isolated, secure, and can interact with network services like the distributed cache.
> **Full guide:** See [docs/SERVERLESS.md](docs/SERVERLESS.md) for host functions API, secrets management, PubSub triggers, and examples.
### 1. Build Functions
Functions must be compiled to WASM. We recommend using [TinyGo](https://tinygo.org/).

374
docs/SERVERLESS.md Normal file
View File

@ -0,0 +1,374 @@
# Serverless Functions
Orama Network runs serverless functions as sandboxed WebAssembly (WASM) modules. Functions are written in Go, compiled to WASM with TinyGo, and executed in an isolated wazero runtime with configurable memory limits and timeouts.
Functions receive input via **stdin** (JSON) and return output via **stdout** (JSON). They can also access Orama services — database, cache, storage, secrets, PubSub, and HTTP — through **host functions** injected by the runtime.
## Quick Start
```bash
# 1. Scaffold a new function
orama function init my-function
# 2. Edit your handler
cd my-function
# edit function.go
# 3. Build to WASM
orama function build
# 4. Deploy
orama function deploy
# 5. Invoke
orama function invoke my-function --data '{"name": "World"}'
# 6. View logs
orama function logs my-function
```
## Project Structure
```
my-function/
├── function.go # Handler code
└── function.yaml # Configuration
```
### function.yaml
```yaml
name: my-function # Required. Letters, digits, hyphens, underscores.
public: false # Allow unauthenticated invocation (default: false)
memory: 64 # Memory limit in MB (1-256, default: 64)
timeout: 30 # Execution timeout in seconds (1-300, default: 30)
retry:
count: 0 # Retry attempts on failure (default: 0)
delay: 5 # Seconds between retries (default: 5)
env: # Environment variables (accessible via get_env)
MY_VAR: "value"
```
### function.go (minimal)
```go
package main
import (
"encoding/json"
"os"
)
func main() {
// Read JSON input from stdin
var input []byte
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
input = append(input, buf[:n]...)
}
if err != nil {
break
}
}
var payload map[string]interface{}
json.Unmarshal(input, &payload)
// Process and return JSON output via stdout
response := map[string]interface{}{
"result": "Hello!",
}
output, _ := json.Marshal(response)
os.Stdout.Write(output)
}
```
### Building
Functions are compiled to WASM using [TinyGo](https://tinygo.org/):
```bash
# Using the CLI (recommended)
orama function build
# Or manually
tinygo build -o function.wasm -target wasi function.go
```
## Host Functions API
Host functions let your WASM code interact with Orama services. They are imported from the `"env"` or `"host"` module (both work) and use a pointer/length ABI for string parameters.
All host functions are registered at runtime by the engine. They are available to every function without additional configuration.
### Context
| Function | Description |
|----------|-------------|
| `get_caller_wallet()` → string | Wallet address of the caller (from JWT) |
| `get_request_id()` → string | Unique invocation ID |
| `get_env(key)` → string | Environment variable from function.yaml |
| `get_secret(name)` → string | Decrypted secret value (see [Managing Secrets](#managing-secrets)) |
### Database (RQLite)
| Function | Description |
|----------|-------------|
| `db_query(sql, argsJSON)` → JSON | Execute SELECT query. Args as JSON array. Returns JSON array of row objects. |
| `db_execute(sql, argsJSON)` → int | Execute INSERT/UPDATE/DELETE. Returns affected row count. |
Example query from WASM:
```
db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?", '["user123"]')
→ [{"push_token": "abc...", "device_type": "ios"}]
```
### Cache (Olric Distributed Cache)
| Function | Description |
|----------|-------------|
| `cache_get(key)` → bytes | Get cached value by key. Returns empty on miss. |
| `cache_set(key, value, ttl)` | Store value with TTL in seconds. |
| `cache_incr(key)` → int64 | Atomically increment by 1 (init to 0 if missing). |
| `cache_incr_by(key, delta)` → int64 | Atomically increment by delta. |
### HTTP
| Function | Description |
|----------|-------------|
| `http_fetch(method, url, headersJSON, body)` → JSON | Make outbound HTTP request. Headers as JSON object. Returns `{"status": 200, "headers": {...}, "body": "..."}`. Timeout: 30s. |
### PubSub
| Function | Description |
|----------|-------------|
| `pubsub_publish(topic, dataJSON)` → bool | Publish message to a PubSub topic. Returns true on success. |
### Logging
| Function | Description |
|----------|-------------|
| `log_info(message)` | Log info-level message (captured in invocation logs). |
| `log_error(message)` | Log error-level message. |
## Managing Secrets
Secrets are encrypted at rest (AES-256-GCM) and scoped to your namespace. Functions read them via `get_secret("name")` at runtime.
### CLI Commands
```bash
# Set a secret (inline value)
orama function secrets set APNS_KEY_ID "ABC123DEF"
# Set a secret from a file (useful for PEM keys, certificates)
orama function secrets set APNS_AUTH_KEY --from-file ./AuthKey_ABC123.p8
# List all secret names (values are never shown)
orama function secrets list
# Delete a secret
orama function secrets delete APNS_KEY_ID
# Delete without confirmation
orama function secrets delete APNS_KEY_ID --force
```
### How It Works
1. **You set secrets** via the CLI → encrypted and stored in the database
2. **Functions read secrets** at runtime via `get_secret("name")` → decrypted on demand
3. **Namespace isolation** → each namespace has its own secret store; functions in namespace A cannot read secrets from namespace B
## PubSub Triggers
Triggers let functions react to events automatically. When a message is published to a PubSub topic, all functions with a trigger on that topic are invoked asynchronously.
### CLI Commands
```bash
# Add a trigger: invoke "call-push-handler" when messages hit "calls:invite"
orama function triggers add call-push-handler --topic calls:invite
# List triggers for a function
orama function triggers list call-push-handler
# Delete a trigger
orama function triggers delete call-push-handler <trigger-id>
```
### Trigger Event Payload
When triggered via PubSub, the function receives this JSON via stdin:
```json
{
"topic": "calls:invite",
"data": { ... },
"namespace": "my-namespace",
"trigger_depth": 1,
"timestamp": 1708972800
}
```
### Depth Limiting
To prevent infinite loops (function A publishes to topic → triggers function A again), trigger depth is tracked. Maximum depth is **5**. If a function's output triggers another function, `trigger_depth` increments. At depth 5, no further triggers fire.
## Function Lifecycle
### Versioning
Each deploy creates a new version. The WASM binary is stored in **IPFS** (content-addressed) and metadata is stored in **RQLite**.
```bash
# List versions
orama function versions my-function
# Invoke a specific version
curl -X POST .../v1/functions/my-function@2/invoke
```
### Invocation Logging
Every invocation is logged with: request ID, duration, status (success/error/timeout), input/output size, and any `log_info`/`log_error` messages.
```bash
orama function logs my-function
```
## CLI Reference
| Command | Description |
|---------|-------------|
| `orama function init <name>` | Scaffold a new function project |
| `orama function build [dir]` | Compile Go to WASM |
| `orama function deploy [dir]` | Deploy WASM to the network |
| `orama function invoke <name> --data <json>` | Invoke a function |
| `orama function list` | List deployed functions |
| `orama function get <name>` | Get function details |
| `orama function delete <name>` | Delete a function |
| `orama function logs <name>` | View invocation logs |
| `orama function versions <name>` | List function versions |
| `orama function secrets set <name> <value>` | Set an encrypted secret |
| `orama function secrets list` | List secret names |
| `orama function secrets delete <name>` | Delete a secret |
| `orama function triggers add <fn> --topic <t>` | Add PubSub trigger |
| `orama function triggers list <fn>` | List triggers |
| `orama function triggers delete <fn> <id>` | Delete a trigger |
## HTTP API Reference
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/v1/functions` | Deploy function (multipart/form-data) |
| GET | `/v1/functions` | List functions |
| GET | `/v1/functions/{name}` | Get function info |
| DELETE | `/v1/functions/{name}` | Delete function |
| POST | `/v1/functions/{name}/invoke` | Invoke function |
| GET | `/v1/functions/{name}/versions` | List versions |
| GET | `/v1/functions/{name}/logs` | Get logs |
| WS | `/v1/functions/{name}/ws` | WebSocket invoke (streaming) |
| PUT | `/v1/functions/secrets` | Set a secret |
| GET | `/v1/functions/secrets` | List secret names |
| DELETE | `/v1/functions/secrets/{name}` | Delete a secret |
| POST | `/v1/functions/{name}/triggers` | Add PubSub trigger |
| GET | `/v1/functions/{name}/triggers` | List triggers |
| DELETE | `/v1/functions/{name}/triggers/{id}` | Delete trigger |
| POST | `/v1/invoke/{namespace}/{name}` | Direct invoke (alt endpoint) |
## Example: Call Push Handler
A real-world function that sends VoIP push notifications when a call invite is published to PubSub:
```yaml
# function.yaml
name: call-push-handler
memory: 128
timeout: 30
```
```go
// function.go — triggered by PubSub on "calls:invite"
package main
import (
"encoding/json"
"os"
)
// This function:
// 1. Receives a call invite event from PubSub trigger
// 2. Queries the database for the callee's device info
// 3. Reads push notification credentials from secrets
// 4. Sends a push notification via http_fetch
func main() {
// Read PubSub trigger event from stdin
var input []byte
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
input = append(input, buf[:n]...)
}
if err != nil {
break
}
}
// Parse the trigger event wrapper
var event struct {
Topic string `json:"topic"`
Data json.RawMessage `json:"data"`
}
json.Unmarshal(input, &event)
// Parse the actual call invite data
var invite struct {
CalleeID string `json:"calleeId"`
CallerName string `json:"callerName"`
CallType string `json:"callType"`
}
json.Unmarshal(event.Data, &invite)
// At this point, the function would use host functions:
//
// 1. db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?",
// json.Marshal([]string{invite.CalleeID}))
//
// 2. get_secret("FCM_SERVER_KEY") for Android push
// get_secret("APNS_KEY_PEM") for iOS push
//
// 3. http_fetch("POST", "https://fcm.googleapis.com/v1/...", headers, body)
//
// 4. log_info("Push sent to " + invite.CalleeID)
//
// Note: Host functions use the WASM ABI (pointer/length).
// A Go SDK for ergonomic access is planned.
response := map[string]interface{}{
"status": "sent",
"callee": invite.CalleeID,
}
output, _ := json.Marshal(response)
os.Stdout.Write(output)
}
```
Deploy and wire the trigger:
```bash
orama function build
orama function deploy
# Set push notification secrets
orama function secrets set FCM_SERVER_KEY "your-fcm-key"
orama function secrets set APNS_KEY_PEM --from-file ./AuthKey.p8
orama function secrets set APNS_KEY_ID "ABC123"
orama function secrets set APNS_TEAM_ID "TEAM456"
# Wire the PubSub trigger
orama function triggers add call-push-handler --topic calls:invite
```

View File

@ -33,4 +33,6 @@ func init() {
Cmd.AddCommand(functions.DeleteCmd)
Cmd.AddCommand(functions.LogsCmd)
Cmd.AddCommand(functions.VersionsCmd)
Cmd.AddCommand(functions.SecretsCmd)
Cmd.AddCommand(functions.TriggersCmd)
}

View File

@ -0,0 +1,156 @@
package functions
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
)
var (
secretsDeleteForce bool
secretsFromFile string
)
// SecretsCmd is the parent command for secrets management.
var SecretsCmd = &cobra.Command{
Use: "secrets",
Short: "Manage function secrets",
Long: `Set, list, and delete encrypted secrets for your serverless functions.
Functions access secrets at runtime via the get_secret() host function.
Secrets are scoped to your namespace and encrypted at rest with AES-256-GCM.
Examples:
orama function secrets set API_KEY "sk-abc123"
orama function secrets set CERT_PEM --from-file ./cert.pem
orama function secrets list
orama function secrets delete API_KEY`,
}
// SecretsSetCmd stores an encrypted secret.
var SecretsSetCmd = &cobra.Command{
Use: "set <name> [value]",
Short: "Set a secret",
Long: `Stores an encrypted secret. Functions access it via get_secret("name"). If --from-file is used, value is read from the file instead.`,
Args: cobra.RangeArgs(1, 2),
RunE: runSecretsSet,
}
// SecretsListCmd lists secret names.
var SecretsListCmd = &cobra.Command{
Use: "list",
Short: "List secret names",
Long: "Lists all secret names in the current namespace. Values are never shown.",
Args: cobra.NoArgs,
RunE: runSecretsList,
}
// SecretsDeleteCmd deletes a secret.
var SecretsDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete a secret",
Long: "Permanently deletes a secret. Functions will no longer be able to access it.",
Args: cobra.ExactArgs(1),
RunE: runSecretsDelete,
}
func init() {
SecretsCmd.AddCommand(SecretsSetCmd)
SecretsCmd.AddCommand(SecretsListCmd)
SecretsCmd.AddCommand(SecretsDeleteCmd)
SecretsSetCmd.Flags().StringVar(&secretsFromFile, "from-file", "", "Read secret value from a file")
SecretsDeleteCmd.Flags().BoolVarP(&secretsDeleteForce, "force", "f", false, "Skip confirmation prompt")
}
func runSecretsSet(cmd *cobra.Command, args []string) error {
name := args[0]
var value string
if secretsFromFile != "" {
data, err := os.ReadFile(secretsFromFile)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", secretsFromFile, err)
}
value = string(data)
} else if len(args) >= 2 {
value = args[1]
} else {
return fmt.Errorf("secret value required: provide as argument or use --from-file")
}
body, _ := json.Marshal(map[string]string{
"name": name,
"value": value,
})
resp, err := apiRequest("PUT", "/v1/functions/secrets", bytes.NewReader(body), "application/json")
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
fmt.Printf("Secret %q set successfully.\n", name)
return nil
}
func runSecretsList(cmd *cobra.Command, args []string) error {
result, err := apiGet("/v1/functions/secrets")
if err != nil {
return err
}
secrets, _ := result["secrets"].([]interface{})
if len(secrets) == 0 {
fmt.Println("No secrets found.")
return nil
}
fmt.Printf("Secrets (%d):\n", len(secrets))
for _, s := range secrets {
fmt.Printf(" %s\n", s)
}
return nil
}
func runSecretsDelete(cmd *cobra.Command, args []string) error {
name := args[0]
if !secretsDeleteForce {
fmt.Printf("Are you sure you want to delete secret %q? [y/N] ", name)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Cancelled.")
return nil
}
}
result, err := apiDelete("/v1/functions/secrets/" + name)
if err != nil {
return err
}
if msg, ok := result["message"]; ok {
fmt.Println(msg)
} else {
fmt.Printf("Secret %q deleted.\n", name)
}
return nil
}

View File

@ -0,0 +1,151 @@
package functions
import (
"bytes"
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"github.com/spf13/cobra"
)
var triggerTopic string
// TriggersCmd is the parent command for trigger management.
var TriggersCmd = &cobra.Command{
Use: "triggers",
Short: "Manage function PubSub triggers",
Long: `Add, list, and delete PubSub triggers for your serverless functions.
When a message is published to a topic, all functions with a trigger on
that topic are automatically invoked with the message as input.
Examples:
orama function triggers add my-function --topic calls:invite
orama function triggers list my-function
orama function triggers delete my-function <trigger-id>`,
}
// TriggersAddCmd adds a PubSub trigger to a function.
var TriggersAddCmd = &cobra.Command{
Use: "add <function-name>",
Short: "Add a PubSub trigger",
Long: "Registers a PubSub trigger so the function is invoked when a message is published to the topic.",
Args: cobra.ExactArgs(1),
RunE: runTriggersAdd,
}
// TriggersListCmd lists triggers for a function.
var TriggersListCmd = &cobra.Command{
Use: "list <function-name>",
Short: "List triggers for a function",
Args: cobra.ExactArgs(1),
RunE: runTriggersList,
}
// TriggersDeleteCmd deletes a trigger.
var TriggersDeleteCmd = &cobra.Command{
Use: "delete <function-name> <trigger-id>",
Short: "Delete a trigger",
Args: cobra.ExactArgs(2),
RunE: runTriggersDelete,
}
func init() {
TriggersCmd.AddCommand(TriggersAddCmd)
TriggersCmd.AddCommand(TriggersListCmd)
TriggersCmd.AddCommand(TriggersDeleteCmd)
TriggersAddCmd.Flags().StringVar(&triggerTopic, "topic", "", "PubSub topic to trigger on (required)")
TriggersAddCmd.MarkFlagRequired("topic")
}
func runTriggersAdd(cmd *cobra.Command, args []string) error {
funcName := args[0]
body, _ := json.Marshal(map[string]string{
"topic": triggerTopic,
})
resp, err := apiRequest("POST", "/v1/functions/"+funcName+"/triggers", bytes.NewReader(body), "application/json")
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != 201 && resp.StatusCode != 200 {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
fmt.Printf("Trigger added: %s → %s (id: %s)\n", triggerTopic, funcName, result["trigger_id"])
return nil
}
func runTriggersList(cmd *cobra.Command, args []string) error {
funcName := args[0]
result, err := apiGet("/v1/functions/" + funcName + "/triggers")
if err != nil {
return err
}
triggers, _ := result["triggers"].([]interface{})
if len(triggers) == 0 {
fmt.Printf("No triggers for function %q.\n", funcName)
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tTOPIC\tENABLED")
for _, t := range triggers {
tr, ok := t.(map[string]interface{})
if !ok {
continue
}
id, _ := tr["ID"].(string)
if id == "" {
id, _ = tr["id"].(string)
}
topic, _ := tr["Topic"].(string)
if topic == "" {
topic, _ = tr["topic"].(string)
}
enabled := true
if e, ok := tr["Enabled"].(bool); ok {
enabled = e
} else if e, ok := tr["enabled"].(bool); ok {
enabled = e
}
fmt.Fprintf(w, "%s\t%s\t%v\n", id, topic, enabled)
}
w.Flush()
return nil
}
func runTriggersDelete(cmd *cobra.Command, args []string) error {
funcName := args[0]
triggerID := args[1]
result, err := apiDelete("/v1/functions/" + funcName + "/triggers/" + triggerID)
if err != nil {
return err
}
if msg, ok := result["message"]; ok {
fmt.Println(msg)
} else {
fmt.Println("Trigger deleted.")
}
return nil
}

View File

@ -459,6 +459,7 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
deps.ServerlessWSMgr,
triggerStore,
deps.PubSubDispatcher,
secretsMgr,
logger.Logger,
)

View File

@ -94,6 +94,7 @@ func newTestHandlers(reg serverless.FunctionRegistry) *ServerlessHandlers {
wsManager,
nil, // triggerStore
nil, // dispatcher
nil, // secretsManager
logger,
)
}

View File

@ -39,6 +39,9 @@ func (h *ServerlessHandlers) handleFunctions(w http.ResponseWriter, r *http.Requ
// - POST /v1/functions/{name}/triggers - Add trigger
// - GET /v1/functions/{name}/triggers - List triggers
// - DELETE /v1/functions/{name}/triggers/{id} - Remove trigger
// - PUT /v1/functions/secrets - Set a secret
// - GET /v1/functions/secrets - List secrets
// - DELETE /v1/functions/secrets/{name} - Delete a secret
func (h *ServerlessHandlers) handleFunctionByName(w http.ResponseWriter, r *http.Request) {
// Parse path: /v1/functions/{name}[/{action}[/{subID}]]
path := strings.TrimPrefix(r.URL.Path, "/v1/functions/")
@ -55,6 +58,22 @@ func (h *ServerlessHandlers) handleFunctionByName(w http.ResponseWriter, r *http
action = parts[1]
}
// Handle secrets management: /v1/functions/secrets[/{secretName}]
if name == "secrets" {
secretName := action // empty for list/set, secret name for delete
switch {
case secretName != "" && r.Method == http.MethodDelete:
h.HandleDeleteSecret(w, r, secretName)
case secretName == "" && r.Method == http.MethodPut:
h.HandleSetSecret(w, r)
case secretName == "" && r.Method == http.MethodGet:
h.HandleListSecrets(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
return
}
// Parse version from name if present (e.g., "myfunction@2")
version := 0
if idx := strings.Index(name, "@"); idx > 0 {

View File

@ -0,0 +1,146 @@
package serverless
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/DeBrosOfficial/network/pkg/serverless"
"go.uber.org/zap"
)
// setSecretRequest is the request body for setting a secret.
type setSecretRequest struct {
Name string `json:"name"`
Value string `json:"value"`
}
// HandleSetSecret handles PUT /v1/functions/secrets
// Stores an encrypted secret scoped to the caller's namespace.
func (h *ServerlessHandlers) HandleSetSecret(w http.ResponseWriter, r *http.Request) {
if h.secretsManager == nil {
writeError(w, http.StatusNotImplemented, "Secrets management not available")
return
}
namespace := h.getNamespaceFromRequest(r)
if namespace == "" {
writeError(w, http.StatusBadRequest, "namespace required")
return
}
var req setSecretRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "secret name required")
return
}
if req.Value == "" {
writeError(w, http.StatusBadRequest, "secret value required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.secretsManager.Set(ctx, namespace, req.Name, req.Value); err != nil {
h.logger.Error("Failed to set secret",
zap.String("namespace", namespace),
zap.String("name", req.Name),
zap.Error(err),
)
writeError(w, http.StatusInternalServerError, "Failed to set secret: "+err.Error())
return
}
h.logger.Info("Secret set via API",
zap.String("namespace", namespace),
zap.String("name", req.Name),
)
writeJSON(w, http.StatusOK, map[string]any{
"message": "Secret set",
"name": req.Name,
"namespace": namespace,
})
}
// HandleListSecrets handles GET /v1/functions/secrets
// Lists all secret names in the caller's namespace (values are never returned).
func (h *ServerlessHandlers) HandleListSecrets(w http.ResponseWriter, r *http.Request) {
if h.secretsManager == nil {
writeError(w, http.StatusNotImplemented, "Secrets management not available")
return
}
namespace := h.getNamespaceFromRequest(r)
if namespace == "" {
writeError(w, http.StatusBadRequest, "namespace required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
names, err := h.secretsManager.List(ctx, namespace)
if err != nil {
h.logger.Error("Failed to list secrets",
zap.String("namespace", namespace),
zap.Error(err),
)
writeError(w, http.StatusInternalServerError, "Failed to list secrets")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"secrets": names,
"count": len(names),
})
}
// HandleDeleteSecret handles DELETE /v1/functions/secrets/{name}
// Deletes a secret from the caller's namespace.
func (h *ServerlessHandlers) HandleDeleteSecret(w http.ResponseWriter, r *http.Request, secretName string) {
if h.secretsManager == nil {
writeError(w, http.StatusNotImplemented, "Secrets management not available")
return
}
namespace := h.getNamespaceFromRequest(r)
if namespace == "" {
writeError(w, http.StatusBadRequest, "namespace required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.secretsManager.Delete(ctx, namespace, secretName); err != nil {
if errors.Is(err, serverless.ErrSecretNotFound) {
writeError(w, http.StatusNotFound, "Secret not found")
return
}
h.logger.Error("Failed to delete secret",
zap.String("namespace", namespace),
zap.String("name", secretName),
zap.Error(err),
)
writeError(w, http.StatusInternalServerError, "Failed to delete secret: "+err.Error())
return
}
h.logger.Info("Secret deleted via API",
zap.String("namespace", namespace),
zap.String("name", secretName),
)
writeJSON(w, http.StatusOK, map[string]any{
"message": "Secret deleted",
})
}

View File

@ -0,0 +1,339 @@
package serverless
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DeBrosOfficial/network/pkg/serverless"
"go.uber.org/zap"
)
// ---------------------------------------------------------------------------
// Mock SecretsManager
// ---------------------------------------------------------------------------
type mockSecretsManager struct {
secrets map[string]map[string]string // namespace -> name -> value
setErr error
getErr error
listErr error
delErr error
}
func newMockSecretsManager() *mockSecretsManager {
return &mockSecretsManager{
secrets: make(map[string]map[string]string),
}
}
func (m *mockSecretsManager) Set(_ context.Context, namespace, name, value string) error {
if m.setErr != nil {
return m.setErr
}
if m.secrets[namespace] == nil {
m.secrets[namespace] = make(map[string]string)
}
m.secrets[namespace][name] = value
return nil
}
func (m *mockSecretsManager) Get(_ context.Context, namespace, name string) (string, error) {
if m.getErr != nil {
return "", m.getErr
}
ns, ok := m.secrets[namespace]
if !ok {
return "", serverless.ErrSecretNotFound
}
v, ok := ns[name]
if !ok {
return "", serverless.ErrSecretNotFound
}
return v, nil
}
func (m *mockSecretsManager) List(_ context.Context, namespace string) ([]string, error) {
if m.listErr != nil {
return nil, m.listErr
}
ns := m.secrets[namespace]
names := make([]string, 0, len(ns))
for k := range ns {
names = append(names, k)
}
return names, nil
}
func (m *mockSecretsManager) Delete(_ context.Context, namespace, name string) error {
if m.delErr != nil {
return m.delErr
}
ns, ok := m.secrets[namespace]
if !ok {
return serverless.ErrSecretNotFound
}
if _, ok := ns[name]; !ok {
return serverless.ErrSecretNotFound
}
delete(ns, name)
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func newSecretsTestHandlers(sm serverless.SecretsManager) *ServerlessHandlers {
logger := zap.NewNop()
wsManager := serverless.NewWSManager(logger)
return NewServerlessHandlers(
nil,
newMockRegistry(),
wsManager,
nil,
nil,
sm,
logger,
)
}
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
var result map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestHandleSetSecret_Success(t *testing.T) {
sm := newMockSecretsManager()
h := newSecretsTestHandlers(sm)
body := `{"name":"API_KEY","value":"secret123"}`
req := httptest.NewRequest(http.MethodPut, "/v1/functions/secrets?namespace=myns", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleSetSecret(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
resp := decodeJSON(t, rec)
if resp["name"] != "API_KEY" {
t.Errorf("expected name API_KEY, got %v", resp["name"])
}
// Verify stored
if sm.secrets["myns"]["API_KEY"] != "secret123" {
t.Errorf("secret not stored correctly")
}
}
func TestHandleSetSecret_MissingName(t *testing.T) {
h := newSecretsTestHandlers(newMockSecretsManager())
body := `{"value":"secret123"}`
req := httptest.NewRequest(http.MethodPut, "/v1/functions/secrets?namespace=myns", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleSetSecret(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
}
func TestHandleSetSecret_MissingValue(t *testing.T) {
h := newSecretsTestHandlers(newMockSecretsManager())
body := `{"name":"API_KEY"}`
req := httptest.NewRequest(http.MethodPut, "/v1/functions/secrets?namespace=myns", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleSetSecret(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
}
func TestHandleSetSecret_NilManager(t *testing.T) {
h := newSecretsTestHandlers(nil)
body := `{"name":"API_KEY","value":"secret123"}`
req := httptest.NewRequest(http.MethodPut, "/v1/functions/secrets?namespace=myns", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleSetSecret(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Errorf("expected 501, got %d", rec.Code)
}
}
func TestHandleListSecrets_Empty(t *testing.T) {
h := newSecretsTestHandlers(newMockSecretsManager())
req := httptest.NewRequest(http.MethodGet, "/v1/functions/secrets?namespace=myns", nil)
rec := httptest.NewRecorder()
h.HandleListSecrets(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
resp := decodeJSON(t, rec)
if resp["count"].(float64) != 0 {
t.Errorf("expected count 0, got %v", resp["count"])
}
}
func TestHandleListSecrets_Populated(t *testing.T) {
sm := newMockSecretsManager()
sm.secrets["myns"] = map[string]string{
"KEY_A": "val_a",
"KEY_B": "val_b",
}
h := newSecretsTestHandlers(sm)
req := httptest.NewRequest(http.MethodGet, "/v1/functions/secrets?namespace=myns", nil)
rec := httptest.NewRecorder()
h.HandleListSecrets(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
resp := decodeJSON(t, rec)
if resp["count"].(float64) != 2 {
t.Errorf("expected count 2, got %v", resp["count"])
}
}
func TestHandleListSecrets_NilManager(t *testing.T) {
h := newSecretsTestHandlers(nil)
req := httptest.NewRequest(http.MethodGet, "/v1/functions/secrets?namespace=myns", nil)
rec := httptest.NewRecorder()
h.HandleListSecrets(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Errorf("expected 501, got %d", rec.Code)
}
}
func TestHandleDeleteSecret_Success(t *testing.T) {
sm := newMockSecretsManager()
sm.secrets["myns"] = map[string]string{"API_KEY": "val"}
h := newSecretsTestHandlers(sm)
req := httptest.NewRequest(http.MethodDelete, "/v1/functions/secrets/API_KEY?namespace=myns", nil)
rec := httptest.NewRecorder()
h.HandleDeleteSecret(rec, req, "API_KEY")
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
if _, exists := sm.secrets["myns"]["API_KEY"]; exists {
t.Error("secret should have been deleted")
}
}
func TestHandleDeleteSecret_NotFound(t *testing.T) {
h := newSecretsTestHandlers(newMockSecretsManager())
req := httptest.NewRequest(http.MethodDelete, "/v1/functions/secrets/MISSING?namespace=myns", nil)
rec := httptest.NewRecorder()
h.HandleDeleteSecret(rec, req, "MISSING")
if rec.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rec.Code)
}
}
func TestHandleDeleteSecret_NilManager(t *testing.T) {
h := newSecretsTestHandlers(nil)
req := httptest.NewRequest(http.MethodDelete, "/v1/functions/secrets/KEY?namespace=myns", nil)
rec := httptest.NewRecorder()
h.HandleDeleteSecret(rec, req, "KEY")
if rec.Code != http.StatusNotImplemented {
t.Errorf("expected 501, got %d", rec.Code)
}
}
// Test routing through handleFunctionByName
func TestRouting_SecretsSet(t *testing.T) {
sm := newMockSecretsManager()
h := newSecretsTestHandlers(sm)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body := `{"name":"MY_SECRET","value":"myval"}`
req := httptest.NewRequest(http.MethodPut, "/v1/functions/secrets?namespace=test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRouting_SecretsList(t *testing.T) {
h := newSecretsTestHandlers(newMockSecretsManager())
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/v1/functions/secrets?namespace=test", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRouting_SecretsDelete(t *testing.T) {
sm := newMockSecretsManager()
sm.secrets["test"] = map[string]string{"KEY": "val"}
h := newSecretsTestHandlers(sm)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodDelete, "/v1/functions/secrets/KEY?namespace=test", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}

View File

@ -13,12 +13,13 @@ import (
// ServerlessHandlers contains handlers for serverless function endpoints.
// It's a separate struct to keep the Gateway struct clean.
type ServerlessHandlers struct {
invoker *serverless.Invoker
registry serverless.FunctionRegistry
wsManager *serverless.WSManager
triggerStore *triggers.PubSubTriggerStore
dispatcher *triggers.PubSubDispatcher
logger *zap.Logger
invoker *serverless.Invoker
registry serverless.FunctionRegistry
wsManager *serverless.WSManager
triggerStore *triggers.PubSubTriggerStore
dispatcher *triggers.PubSubDispatcher
secretsManager serverless.SecretsManager
logger *zap.Logger
}
// NewServerlessHandlers creates a new ServerlessHandlers instance.
@ -28,15 +29,17 @@ func NewServerlessHandlers(
wsManager *serverless.WSManager,
triggerStore *triggers.PubSubTriggerStore,
dispatcher *triggers.PubSubDispatcher,
secretsManager serverless.SecretsManager,
logger *zap.Logger,
) *ServerlessHandlers {
return &ServerlessHandlers{
invoker: invoker,
registry: registry,
wsManager: wsManager,
triggerStore: triggerStore,
dispatcher: dispatcher,
logger: logger,
invoker: invoker,
registry: registry,
wsManager: wsManager,
triggerStore: triggerStore,
dispatcher: dispatcher,
secretsManager: secretsManager,
logger: logger,
}
}

View File

@ -50,7 +50,7 @@ func TestServerlessHandlers_ListFunctions(t *testing.T) {
},
}
h := serverlesshandlers.NewServerlessHandlers(nil, registry, nil, nil, nil, logger)
h := serverlesshandlers.NewServerlessHandlers(nil, registry, nil, nil, nil, nil, logger)
req, _ := http.NewRequest("GET", "/v1/functions?namespace=ns1", nil)
rr := httptest.NewRecorder()
@ -73,7 +73,7 @@ func TestServerlessHandlers_DeployFunction(t *testing.T) {
logger := zap.NewNop()
registry := &mockFunctionRegistry{}
h := serverlesshandlers.NewServerlessHandlers(nil, registry, nil, nil, nil, logger)
h := serverlesshandlers.NewServerlessHandlers(nil, registry, nil, nil, nil, nil, logger)
// Test JSON deploy (which is partially supported according to code)
// Should be 400 because WASM is missing or base64 not supported

View File

@ -69,6 +69,22 @@ run_ssh() {
fi
}
# Like run_ssh but without -n, so stdin can be piped through
run_ssh_stdin() {
local user="$1" host="$2" pass="$3" key="$4"
shift 4
local opts="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
if [ -n "$key" ]; then
ssh $opts -i "$key" "$user@$host" "$@"
elif [ -n "$pass" ]; then
sshpass -p "$pass" ssh $opts \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
"$user@$host" "$@"
else
ssh $opts "$user@$host" "$@"
fi
}
run_scp() {
local user="$1" host="$2" pass="$3" key="$4" src="$5" dst="$6"
local opts="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
@ -134,7 +150,7 @@ for ((j=1; j<TOTAL; j++)); do
done
# Upload targets file and fanout script to seed
run_ssh "$SEED_USER" "$SEED_HOST" "$SEED_PASS" "$SEED_KEY" "cat > /tmp/fanout-targets.txt" <<< "$TARGETS_CONTENT"
run_ssh_stdin "$SEED_USER" "$SEED_HOST" "$SEED_PASS" "$SEED_KEY" "cat > /tmp/fanout-targets.txt" <<< "$TARGETS_CONTENT"
FANOUT='#!/bin/bash
ARCHIVE="/tmp/network-source.tar.gz"
@ -175,7 +191,7 @@ rm -f /tmp/fanout-targets.txt /tmp/fanout.sh
exit $FAILED
'
run_ssh "$SEED_USER" "$SEED_HOST" "$SEED_PASS" "$SEED_KEY" "cat > /tmp/fanout.sh && chmod +x /tmp/fanout.sh" <<< "$FANOUT"
run_ssh_stdin "$SEED_USER" "$SEED_HOST" "$SEED_PASS" "$SEED_KEY" "cat > /tmp/fanout.sh && chmod +x /tmp/fanout.sh" <<< "$FANOUT"
# Run fanout (allocate tty for live output)
run_ssh "$SEED_USER" "$SEED_HOST" "$SEED_PASS" "$SEED_KEY" "bash /tmp/fanout.sh"