diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 11df00f..93aba5d 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -12,6 +12,7 @@ import ( "github.com/DeBrosOfficial/network/pkg/cli/cmd/dbcmd" deploycmd "github.com/DeBrosOfficial/network/pkg/cli/cmd/deploy" "github.com/DeBrosOfficial/network/pkg/cli/cmd/envcmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/functioncmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/inspectcmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/monitorcmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/namespacecmd" @@ -79,6 +80,9 @@ and interacting with the Orama distributed network.`, // Monitor command rootCmd.AddCommand(monitorcmd.Cmd) + // Serverless function commands + rootCmd.AddCommand(functioncmd.Cmd) + return rootCmd } diff --git a/pkg/cli/cmd/functioncmd/function.go b/pkg/cli/cmd/functioncmd/function.go new file mode 100644 index 0000000..2b7ec5b --- /dev/null +++ b/pkg/cli/cmd/functioncmd/function.go @@ -0,0 +1,36 @@ +package functioncmd + +import ( + "github.com/DeBrosOfficial/network/pkg/cli/functions" + "github.com/spf13/cobra" +) + +// Cmd is the top-level function command. +var Cmd = &cobra.Command{ + Use: "function", + Short: "Manage serverless functions", + Long: `Deploy, invoke, and manage serverless functions on the Orama Network. + +A function is a folder containing: + function.go — your handler code (uses the fn SDK) + function.yaml — configuration (name, memory, timeout, etc.) + +Quick start: + orama function init my-function + cd my-function + orama function build + orama function deploy + orama function invoke my-function --data '{"name": "World"}'`, +} + +func init() { + Cmd.AddCommand(functions.InitCmd) + Cmd.AddCommand(functions.BuildCmd) + Cmd.AddCommand(functions.DeployCmd) + Cmd.AddCommand(functions.InvokeCmd) + Cmd.AddCommand(functions.ListCmd) + Cmd.AddCommand(functions.GetCmd) + Cmd.AddCommand(functions.DeleteCmd) + Cmd.AddCommand(functions.LogsCmd) + Cmd.AddCommand(functions.VersionsCmd) +} diff --git a/pkg/cli/functions/build.go b/pkg/cli/functions/build.go new file mode 100644 index 0000000..d9b44c9 --- /dev/null +++ b/pkg/cli/functions/build.go @@ -0,0 +1,79 @@ +package functions + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +// BuildCmd compiles a function to WASM using TinyGo. +var BuildCmd = &cobra.Command{ + Use: "build [directory]", + Short: "Build a function to WASM using TinyGo", + Long: `Compiles function.go in the given directory (or current directory) to a WASM binary. +Requires TinyGo to be installed (https://tinygo.org/getting-started/install/).`, + Args: cobra.MaximumNArgs(1), + RunE: runBuild, +} + +func runBuild(cmd *cobra.Command, args []string) error { + dir := "" + if len(args) > 0 { + dir = args[0] + } + _, err := buildFunction(dir) + return err +} + +// buildFunction compiles the function in dir and returns the path to the WASM output. +func buildFunction(dir string) (string, error) { + absDir, err := ResolveFunctionDir(dir) + if err != nil { + return "", err + } + + // Verify function.go exists + goFile := filepath.Join(absDir, "function.go") + if _, err := os.Stat(goFile); os.IsNotExist(err) { + return "", fmt.Errorf("function.go not found in %s", absDir) + } + + // Verify function.yaml exists + if _, err := os.Stat(filepath.Join(absDir, "function.yaml")); os.IsNotExist(err) { + return "", fmt.Errorf("function.yaml not found in %s", absDir) + } + + // Check TinyGo is installed + tinygoPath, err := exec.LookPath("tinygo") + if err != nil { + return "", fmt.Errorf("tinygo not found in PATH. Install it: https://tinygo.org/getting-started/install/") + } + + outputPath := filepath.Join(absDir, "function.wasm") + + fmt.Printf("Building %s...\n", absDir) + + // Run tinygo build + buildCmd := exec.Command(tinygoPath, "build", "-o", outputPath, "-target", "wasi", ".") + buildCmd.Dir = absDir + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + return "", fmt.Errorf("tinygo build failed: %w", err) + } + + // Validate output + if err := ValidateWASMFile(outputPath); err != nil { + os.Remove(outputPath) + return "", fmt.Errorf("build produced invalid WASM: %w", err) + } + + info, _ := os.Stat(outputPath) + fmt.Printf("Built %s (%d bytes)\n", outputPath, info.Size()) + + return outputPath, nil +} diff --git a/pkg/cli/functions/delete.go b/pkg/cli/functions/delete.go new file mode 100644 index 0000000..71a327c --- /dev/null +++ b/pkg/cli/functions/delete.go @@ -0,0 +1,53 @@ +package functions + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var deleteForce bool + +// DeleteCmd deletes a deployed function. +var DeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a deployed function", + Long: "Deletes a function from the Orama Network. This action cannot be undone.", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func init() { + DeleteCmd.Flags().BoolVarP(&deleteForce, "force", "f", false, "Skip confirmation prompt") +} + +func runDelete(cmd *cobra.Command, args []string) error { + name := args[0] + + if !deleteForce { + fmt.Printf("Are you sure you want to delete function %q? This cannot be undone. [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/" + name) + if err != nil { + return err + } + + if msg, ok := result["message"]; ok { + fmt.Println(msg) + } else { + fmt.Printf("Function %q deleted.\n", name) + } + + return nil +} diff --git a/pkg/cli/functions/deploy.go b/pkg/cli/functions/deploy.go new file mode 100644 index 0000000..a46987b --- /dev/null +++ b/pkg/cli/functions/deploy.go @@ -0,0 +1,89 @@ +package functions + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// DeployCmd deploys a function to the Orama Network. +var DeployCmd = &cobra.Command{ + Use: "deploy [directory]", + Short: "Deploy a function to the Orama Network", + Long: `Deploys the function in the given directory (or current directory). +If no .wasm file exists, it will be built automatically using TinyGo. +Reads configuration from function.yaml.`, + Args: cobra.MaximumNArgs(1), + RunE: runDeploy, +} + +func runDeploy(cmd *cobra.Command, args []string) error { + dir := "" + if len(args) > 0 { + dir = args[0] + } + + absDir, err := ResolveFunctionDir(dir) + if err != nil { + return err + } + + // Load configuration + cfg, err := LoadConfig(absDir) + if err != nil { + return err + } + + wasmPath := filepath.Join(absDir, "function.wasm") + + // Auto-build if no WASM file exists + if _, err := os.Stat(wasmPath); os.IsNotExist(err) { + fmt.Printf("No function.wasm found, building...\n\n") + built, err := buildFunction(dir) + if err != nil { + return err + } + wasmPath = built + fmt.Println() + } else { + // Validate existing WASM + if err := ValidateWASMFile(wasmPath); err != nil { + return fmt.Errorf("existing function.wasm is invalid: %w\nRun 'orama function build' to rebuild", err) + } + } + + fmt.Printf("Deploying function %q...\n", cfg.Name) + + result, err := uploadWASMFunction(wasmPath, cfg) + if err != nil { + return err + } + + fmt.Printf("\nFunction deployed successfully!\n\n") + + if msg, ok := result["message"]; ok { + fmt.Printf(" %s\n", msg) + } + if fn, ok := result["function"].(map[string]interface{}); ok { + if id, ok := fn["id"]; ok { + fmt.Printf(" ID: %s\n", id) + } + fmt.Printf(" Name: %s\n", cfg.Name) + if v, ok := fn["version"]; ok { + fmt.Printf(" Version: %v\n", v) + } + if wc, ok := fn["wasm_cid"]; ok { + fmt.Printf(" WASM CID: %s\n", wc) + } + if st, ok := fn["status"]; ok { + fmt.Printf(" Status: %s\n", st) + } + } + + fmt.Printf("\nInvoke with:\n") + fmt.Printf(" orama function invoke %s --data '{\"name\": \"World\"}'\n", cfg.Name) + + return nil +} diff --git a/pkg/cli/functions/get.go b/pkg/cli/functions/get.go new file mode 100644 index 0000000..20881ce --- /dev/null +++ b/pkg/cli/functions/get.go @@ -0,0 +1,35 @@ +package functions + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +// GetCmd shows details of a deployed function. +var GetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details of a deployed function", + Long: "Retrieves and displays detailed information about a specific function.", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + name := args[0] + + result, err := apiGet("/v1/functions/" + name) + if err != nil { + return err + } + + // Pretty-print the result + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + + fmt.Println(string(data)) + return nil +} diff --git a/pkg/cli/functions/helpers.go b/pkg/cli/functions/helpers.go new file mode 100644 index 0000000..f0baf84 --- /dev/null +++ b/pkg/cli/functions/helpers.go @@ -0,0 +1,260 @@ +package functions + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + + "github.com/DeBrosOfficial/network/pkg/cli/shared" + "gopkg.in/yaml.v3" +) + +// FunctionConfig represents the function.yaml configuration. +type FunctionConfig struct { + Name string `yaml:"name"` + Public bool `yaml:"public"` + Memory int `yaml:"memory"` + Timeout int `yaml:"timeout"` + Retry RetryConfig `yaml:"retry"` + Env map[string]string `yaml:"env"` +} + +// RetryConfig holds retry settings. +type RetryConfig struct { + Count int `yaml:"count"` + Delay int `yaml:"delay"` +} + +// wasmMagicBytes is the WASM binary magic number: \0asm +var wasmMagicBytes = []byte{0x00, 0x61, 0x73, 0x6d} + +// validNameRegex validates function names (alphanumeric, hyphens, underscores). +var validNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`) + +// LoadConfig reads and parses a function.yaml from the given directory. +func LoadConfig(dir string) (*FunctionConfig, error) { + path := filepath.Join(dir, "function.yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read function.yaml: %w", err) + } + + var cfg FunctionConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse function.yaml: %w", err) + } + + // Apply defaults + if cfg.Memory == 0 { + cfg.Memory = 64 + } + if cfg.Timeout == 0 { + cfg.Timeout = 30 + } + if cfg.Retry.Delay == 0 { + cfg.Retry.Delay = 5 + } + + // Validate + if cfg.Name == "" { + return nil, fmt.Errorf("function.yaml: 'name' is required") + } + if !validNameRegex.MatchString(cfg.Name) { + return nil, fmt.Errorf("function.yaml: 'name' must start with a letter and contain only letters, digits, hyphens, or underscores") + } + if cfg.Memory < 1 || cfg.Memory > 256 { + return nil, fmt.Errorf("function.yaml: 'memory' must be between 1 and 256 MB (got %d)", cfg.Memory) + } + if cfg.Timeout < 1 || cfg.Timeout > 300 { + return nil, fmt.Errorf("function.yaml: 'timeout' must be between 1 and 300 seconds (got %d)", cfg.Timeout) + } + + return &cfg, nil +} + +// ValidateWASM checks that the given bytes are a valid WASM binary (magic number check). +func ValidateWASM(data []byte) error { + if len(data) < 8 { + return fmt.Errorf("file too small to be a valid WASM binary (%d bytes)", len(data)) + } + if !bytes.HasPrefix(data, wasmMagicBytes) { + return fmt.Errorf("file is not a valid WASM binary (bad magic bytes)") + } + return nil +} + +// ValidateWASMFile checks that the file at the given path is a valid WASM binary. +func ValidateWASMFile(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open WASM file: %w", err) + } + defer f.Close() + + header := make([]byte, 8) + n, err := f.Read(header) + if err != nil { + return fmt.Errorf("failed to read WASM file: %w", err) + } + return ValidateWASM(header[:n]) +} + +// apiRequest performs an authenticated HTTP request to the gateway API. +func apiRequest(method, endpoint string, body io.Reader, contentType string) (*http.Response, error) { + apiURL := shared.GetAPIURL() + url := apiURL + endpoint + + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + token, err := shared.GetAuthToken() + if err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + return http.DefaultClient.Do(req) +} + +// apiGet performs an authenticated GET request and returns the parsed JSON response. +func apiGet(endpoint string) (map[string]interface{}, error) { + resp, err := apiRequest("GET", endpoint, nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result, nil +} + +// apiDelete performs an authenticated DELETE request and returns the parsed JSON response. +func apiDelete(endpoint string) (map[string]interface{}, error) { + resp, err := apiRequest("DELETE", endpoint, nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result, nil +} + +// uploadWASMFunction uploads a WASM file to the deploy endpoint via multipart/form-data. +func uploadWASMFunction(wasmPath string, cfg *FunctionConfig) (map[string]interface{}, error) { + wasmFile, err := os.Open(wasmPath) + if err != nil { + return nil, fmt.Errorf("failed to open WASM file: %w", err) + } + defer wasmFile.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add form fields + writer.WriteField("name", cfg.Name) + writer.WriteField("is_public", strconv.FormatBool(cfg.Public)) + writer.WriteField("memory_limit_mb", strconv.Itoa(cfg.Memory)) + writer.WriteField("timeout_seconds", strconv.Itoa(cfg.Timeout)) + writer.WriteField("retry_count", strconv.Itoa(cfg.Retry.Count)) + writer.WriteField("retry_delay_seconds", strconv.Itoa(cfg.Retry.Delay)) + + // Add env vars as metadata JSON + if len(cfg.Env) > 0 { + metadata, _ := json.Marshal(map[string]interface{}{ + "env_vars": cfg.Env, + }) + writer.WriteField("metadata", string(metadata)) + } + + // Add WASM file + part, err := writer.CreateFormFile("wasm", filepath.Base(wasmPath)) + if err != nil { + return nil, fmt.Errorf("failed to create form file: %w", err) + } + if _, err := io.Copy(part, wasmFile); err != nil { + return nil, fmt.Errorf("failed to write WASM data: %w", err) + } + writer.Close() + + resp, err := apiRequest("POST", "/v1/functions", body, writer.FormDataContentType()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("deploy failed (%d): %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result, nil +} + +// ResolveFunctionDir resolves and validates a function directory. +// If dir is empty, uses the current working directory. +func ResolveFunctionDir(dir string) (string, error) { + if dir == "" { + dir = "." + } + absDir, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + info, err := os.Stat(absDir) + if err != nil { + return "", fmt.Errorf("directory does not exist: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("%s is not a directory", absDir) + } + return absDir, nil +} diff --git a/pkg/cli/functions/init.go b/pkg/cli/functions/init.go new file mode 100644 index 0000000..66abe94 --- /dev/null +++ b/pkg/cli/functions/init.go @@ -0,0 +1,84 @@ +package functions + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// InitCmd scaffolds a new function project. +var InitCmd = &cobra.Command{ + Use: "init ", + Short: "Create a new serverless function project", + Long: "Scaffolds a new directory with function.go and function.yaml templates.", + Args: cobra.ExactArgs(1), + RunE: runInit, +} + +func runInit(cmd *cobra.Command, args []string) error { + name := args[0] + + if !validNameRegex.MatchString(name) { + return fmt.Errorf("invalid function name %q: must start with a letter and contain only letters, digits, hyphens, or underscores", name) + } + + dir := filepath.Join(".", name) + if _, err := os.Stat(dir); err == nil { + return fmt.Errorf("directory %q already exists", name) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Write function.yaml + yamlContent := fmt.Sprintf(`name: %s +public: false +memory: 64 +timeout: 30 +retry: + count: 0 + delay: 5 +`, name) + + if err := os.WriteFile(filepath.Join(dir, "function.yaml"), []byte(yamlContent), 0o644); err != nil { + return fmt.Errorf("failed to write function.yaml: %w", err) + } + + // Write function.go + goContent := fmt.Sprintf(`package main + +import "github.com/DeBrosOfficial/network/sdk/fn" + +func main() { + fn.Run(func(input []byte) ([]byte, error) { + var req struct { + Name string `+"`"+`json:"name"`+"`"+` + } + fn.ParseJSON(input, &req) + if req.Name == "" { + req.Name = "World" + } + return fn.JSON(map[string]string{ + "greeting": "Hello, " + req.Name + "!", + }) + }) +} +`) + + if err := os.WriteFile(filepath.Join(dir, "function.go"), []byte(goContent), 0o644); err != nil { + return fmt.Errorf("failed to write function.go: %w", err) + } + + fmt.Printf("Created function project: %s/\n", name) + fmt.Printf(" %s/function.yaml — configuration\n", name) + fmt.Printf(" %s/function.go — handler code\n\n", name) + fmt.Printf("Next steps:\n") + fmt.Printf(" cd %s\n", name) + fmt.Printf(" orama function build\n") + fmt.Printf(" orama function deploy\n") + + return nil +} diff --git a/pkg/cli/functions/invoke.go b/pkg/cli/functions/invoke.go new file mode 100644 index 0000000..15fbdf4 --- /dev/null +++ b/pkg/cli/functions/invoke.go @@ -0,0 +1,58 @@ +package functions + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/spf13/cobra" +) + +var invokeData string + +// InvokeCmd invokes a deployed function. +var InvokeCmd = &cobra.Command{ + Use: "invoke ", + Short: "Invoke a deployed function", + Long: "Sends a request to invoke the named function with optional JSON payload.", + Args: cobra.ExactArgs(1), + RunE: runInvoke, +} + +func init() { + InvokeCmd.Flags().StringVar(&invokeData, "data", "{}", "JSON payload to send to the function") +} + +func runInvoke(cmd *cobra.Command, args []string) error { + name := args[0] + + fmt.Printf("Invoking function %q...\n\n", name) + + resp, err := apiRequest("POST", "/v1/functions/"+name+"/invoke", bytes.NewBufferString(invokeData), "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) + } + + // Print timing info from headers + if reqID := resp.Header.Get("X-Request-ID"); reqID != "" { + fmt.Printf("Request ID: %s\n", reqID) + } + if dur := resp.Header.Get("X-Duration-Ms"); dur != "" { + fmt.Printf("Duration: %s ms\n", dur) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invocation failed (%d): %s", resp.StatusCode, string(respBody)) + } + + fmt.Printf("\nOutput:\n%s\n", string(respBody)) + + return nil +} diff --git a/pkg/cli/functions/list.go b/pkg/cli/functions/list.go new file mode 100644 index 0000000..8550346 --- /dev/null +++ b/pkg/cli/functions/list.go @@ -0,0 +1,80 @@ +package functions + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +// ListCmd lists all deployed functions. +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List deployed functions", + Long: "Lists all functions deployed in the current namespace.", + Args: cobra.NoArgs, + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + result, err := apiGet("/v1/functions") + if err != nil { + return err + } + + functions, ok := result["functions"].([]interface{}) + if !ok || len(functions) == 0 { + fmt.Println("No functions deployed.") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tSTATUS\tMEMORY\tTIMEOUT\tPUBLIC") + fmt.Fprintln(w, "----\t-------\t------\t------\t-------\t------") + + for _, f := range functions { + fn, ok := f.(map[string]interface{}) + if !ok { + continue + } + name := valStr(fn, "name") + version := valNum(fn, "version") + status := valStr(fn, "status") + memory := valNum(fn, "memory_limit_mb") + timeout := valNum(fn, "timeout_seconds") + public := valBool(fn, "is_public") + + publicStr := "no" + if public { + publicStr = "yes" + } + + fmt.Fprintf(w, "%s\t%d\t%s\t%dMB\t%ds\t%s\n", name, version, status, memory, timeout, publicStr) + } + w.Flush() + + fmt.Printf("\nTotal: %d function(s)\n", len(functions)) + return nil +} + +func valStr(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + return fmt.Sprintf("%v", v) + } + return "" +} + +func valNum(m map[string]interface{}, key string) int { + if v, ok := m[key].(float64); ok { + return int(v) + } + return 0 +} + +func valBool(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} diff --git a/pkg/cli/functions/logs.go b/pkg/cli/functions/logs.go new file mode 100644 index 0000000..d9d4ae5 --- /dev/null +++ b/pkg/cli/functions/logs.go @@ -0,0 +1,57 @@ +package functions + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var logsLimit int + +// LogsCmd retrieves function execution logs. +var LogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Get execution logs for a function", + Long: "Retrieves the most recent execution logs for a deployed function.", + Args: cobra.ExactArgs(1), + RunE: runLogs, +} + +func init() { + LogsCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum number of log entries to retrieve") +} + +func runLogs(cmd *cobra.Command, args []string) error { + name := args[0] + + endpoint := "/v1/functions/" + name + "/logs" + if logsLimit > 0 { + endpoint += "?limit=" + strconv.Itoa(logsLimit) + } + + result, err := apiGet(endpoint) + if err != nil { + return err + } + + logs, ok := result["logs"].([]interface{}) + if !ok || len(logs) == 0 { + fmt.Printf("No logs found for function %q.\n", name) + return nil + } + + for _, entry := range logs { + log, ok := entry.(map[string]interface{}) + if !ok { + continue + } + ts := valStr(log, "timestamp") + level := valStr(log, "level") + msg := valStr(log, "message") + fmt.Printf("[%s] %s: %s\n", ts, level, msg) + } + + fmt.Printf("\nShowing %d log(s)\n", len(logs)) + return nil +} diff --git a/pkg/cli/functions/versions.go b/pkg/cli/functions/versions.go new file mode 100644 index 0000000..8a2b6b7 --- /dev/null +++ b/pkg/cli/functions/versions.go @@ -0,0 +1,54 @@ +package functions + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +// VersionsCmd lists all versions of a function. +var VersionsCmd = &cobra.Command{ + Use: "versions ", + Short: "List all versions of a function", + Long: "Shows all deployed versions of a specific function.", + Args: cobra.ExactArgs(1), + RunE: runVersions, +} + +func runVersions(cmd *cobra.Command, args []string) error { + name := args[0] + + result, err := apiGet("/v1/functions/" + name + "/versions") + if err != nil { + return err + } + + versions, ok := result["versions"].([]interface{}) + if !ok || len(versions) == 0 { + fmt.Printf("No versions found for function %q.\n", name) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "VERSION\tWASM CID\tSTATUS\tCREATED") + fmt.Fprintln(w, "-------\t--------\t------\t-------") + + for _, v := range versions { + ver, ok := v.(map[string]interface{}) + if !ok { + continue + } + version := valNum(ver, "version") + wasmCID := valStr(ver, "wasm_cid") + status := valStr(ver, "status") + created := valStr(ver, "created_at") + + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", version, wasmCID, status, created) + } + w.Flush() + + fmt.Printf("\nTotal: %d version(s)\n", len(versions)) + return nil +} diff --git a/sdk/fn/fn.go b/sdk/fn/fn.go new file mode 100644 index 0000000..da1d945 --- /dev/null +++ b/sdk/fn/fn.go @@ -0,0 +1,66 @@ +// Package fn provides a tiny, TinyGo-compatible SDK for writing Orama serverless functions. +// +// A function is a Go program that reads JSON input from stdin and writes JSON output to stdout. +// This package handles the boilerplate so you only write your handler logic. +// +// Example: +// +// package main +// +// import "github.com/DeBrosOfficial/network/sdk/fn" +// +// func main() { +// fn.Run(func(input []byte) ([]byte, error) { +// var req struct{ Name string `json:"name"` } +// fn.ParseJSON(input, &req) +// if req.Name == "" { req.Name = "World" } +// return fn.JSON(map[string]string{"greeting": "Hello, " + req.Name + "!"}) +// }) +// } +package fn + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// HandlerFunc is the signature for a serverless function handler. +// It receives the raw JSON input bytes and returns raw JSON output bytes. +type HandlerFunc func(input []byte) (output []byte, err error) + +// Run reads input from stdin, calls the handler, and writes the output to stdout. +// If the handler returns an error, it writes a JSON error response to stdout and exits with code 1. +func Run(handler HandlerFunc) { + input, err := io.ReadAll(os.Stdin) + if err != nil { + writeError(fmt.Sprintf("failed to read input: %v", err)) + os.Exit(1) + } + + output, err := handler(input) + if err != nil { + writeError(err.Error()) + os.Exit(1) + } + + if output != nil { + os.Stdout.Write(output) + } +} + +// JSON marshals a value to JSON bytes. Convenience wrapper around json.Marshal. +func JSON(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +// ParseJSON unmarshals JSON bytes into a value. Convenience wrapper around json.Unmarshal. +func ParseJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +func writeError(msg string) { + resp, _ := json.Marshal(map[string]string{"error": msg}) + os.Stdout.Write(resp) +}