diff --git a/console/main.go b/console/main.go index 64bb2af..9196f82 100644 --- a/console/main.go +++ b/console/main.go @@ -6,9 +6,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "log" + "net" "net/http" "os" "sort" @@ -39,11 +41,12 @@ var ( const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" type server struct { - dyn dynamic.Interface - ns string - routerURL string - http *http.Client - saTokenPath string + dyn dynamic.Interface + ns string + routerURL string + http *http.Client + saTokenPath string + invokeTimeout time.Duration authUser string authPass string @@ -71,6 +74,8 @@ func main() { namespace := envDefault("FISSION_NAMESPACE", "default") routerURL := strings.TrimRight(envDefault("FISSION_ROUTER_URL", "http://router.fission.svc.cluster.local"), "/") port := envDefault("PORT", "8090") + httpTimeout := envDurationDefault("FISSION_HTTP_TIMEOUT", 30*time.Second) + invokeTimeout := envDurationDefault("FISSION_INVOKE_TIMEOUT", 20*time.Second) cfg, err := buildConfig(kubeconfig) if err != nil { @@ -87,13 +92,14 @@ func main() { saTokenPath := envDefault("SA_TOKEN_PATH", defaultSATokenPath) s := &server{ - dyn: dyn, - ns: namespace, - routerURL: routerURL, - http: &http.Client{Timeout: 30 * time.Second}, - saTokenPath: saTokenPath, - authUser: authUser, - authPass: authPass, + dyn: dyn, + ns: namespace, + routerURL: routerURL, + http: &http.Client{Timeout: httpTimeout}, + saTokenPath: saTokenPath, + invokeTimeout: invokeTimeout, + authUser: authUser, + authPass: authPass, } mux := http.NewServeMux() @@ -126,7 +132,7 @@ func main() { httpServer := &http.Server{ Addr: ":" + port, - Handler: withCORS(logRequests(mux)), + Handler: withSecurityHeaders(withCORS(logRequests(mux))), ReadHeaderTimeout: 10 * time.Second, } @@ -341,12 +347,7 @@ func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name if packageName != "" { pkg, pkgErr := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, packageName, metav1.GetOptions{}) if pkgErr == nil { - literal, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal") - if literal != "" { - if decodedCode, decErr := decodeLiteralToSource(literal); decErr == nil { - code = decodedCode - } - } + code = s.extractPackageSourceCode(ctx, pkg) } } @@ -459,7 +460,12 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na bodyBytes = []byte("{}") } - ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + invokeTimeout := s.invokeTimeout + if invokeTimeout <= 0 { + invokeTimeout = 20 * time.Second + } + + ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout) defer cancel() invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name) @@ -516,6 +522,15 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na resp, err := s.http.Do(req) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s: function specialization likely failed (for example, syntax error)", name, invokeTimeout)) + return + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s: function specialization likely failed (for example, syntax error)", name, invokeTimeout)) + return + } writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err)) return } @@ -694,6 +709,17 @@ func withCORS(next http.Handler) http.Handler { }) } +func withSecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content") + next.ServeHTTP(w, r) + }) +} + func envDefault(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v @@ -701,6 +727,19 @@ func envDefault(key, fallback string) string { return fallback } +func envDurationDefault(key string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + log.Printf("invalid duration for %s=%q, using default %s", key, raw, fallback) + return fallback + } + return d +} + func normalizeMethods(in []string) []string { if len(in) == 0 { return []string{"GET"} @@ -721,12 +760,78 @@ func normalizeMethods(in []string) []string { return out } +func (s *server) extractPackageSourceCode(ctx context.Context, pkg *unstructured.Unstructured) string { + literalPaths := [][]string{ + {"spec", "source", "literal"}, + {"spec", "deployment", "literal"}, + } + for _, p := range literalPaths { + literal, found, _ := unstructured.NestedString(pkg.Object, p...) + if !found || strings.TrimSpace(literal) == "" { + continue + } + if decodedCode, decErr := decodeLiteralToSource(literal); decErr == nil && strings.TrimSpace(decodedCode) != "" { + return decodedCode + } + } + + urlPaths := [][]string{ + {"spec", "source", "url"}, + {"spec", "deployment", "url"}, + } + for _, p := range urlPaths { + urlValue, found, _ := unstructured.NestedString(pkg.Object, p...) + if !found || strings.TrimSpace(urlValue) == "" { + continue + } + + archiveBytes, fetchErr := s.fetchPackageArchive(ctx, urlValue) + if fetchErr != nil { + continue + } + + decodedCode, decErr := decodeArchiveBytesToSource(archiveBytes) + if decErr == nil && strings.TrimSpace(decodedCode) != "" { + return decodedCode + } + } + + return "" +} + +func (s *server) fetchPackageArchive(ctx context.Context, archiveURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil) + if err != nil { + return nil, err + } + + resp, err := s.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("archive request failed: %s", resp.Status) + } + + return io.ReadAll(resp.Body) +} + func decodeLiteralToSource(literal string) (string, error) { decoded, err := base64.StdEncoding.DecodeString(literal) if err != nil { return "", err } + return decodeArchiveBytesToSource(decoded) +} + +func decodeArchiveBytesToSource(decoded []byte) (string, error) { + if len(decoded) == 0 { + return "", fmt.Errorf("empty payload") + } + if utf8.Valid(decoded) { return string(decoded), nil } @@ -737,7 +842,7 @@ func decodeLiteralToSource(literal string) (string, error) { } } - return string(decoded), nil + return "", fmt.Errorf("payload does not contain utf-8 source") } func decodeZipSource(zipBytes []byte) (string, error) { diff --git a/console/main_test.go b/console/main_test.go index f0a3e91..b8c0766 100644 --- a/console/main_test.go +++ b/console/main_test.go @@ -142,6 +142,77 @@ func TestUpdateFunctionCode(t *testing.T) { } } +func TestGetFunctionUsesSourceLiteralWhenDeploymentLiteralMissing(t *testing.T) { + env := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Environment", + "metadata": map[string]any{"name": "go-acc", "namespace": "default"}, + }} + + s := newTestServer(env) + + pkg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{ + "name": "fn-go-acc-pkg", + "namespace": "default", + }, + "spec": map[string]any{ + "source": map[string]any{ + "literal": base64.StdEncoding.EncodeToString([]byte("package main\n\nfunc Handler() {}\n")), + }, + "deployment": map[string]any{ + "type": "url", + "url": "http://storagesvc.fission/v1/archive?id=dummy", + }, + }, + }} + + fn := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": "fn-go-acc", + "namespace": "default", + }, + "spec": map[string]any{ + "environment": map[string]any{"name": "go-acc", "namespace": "default"}, + "package": map[string]any{ + "functionName": "Handler", + "packageref": map[string]any{ + "name": "fn-go-acc-pkg", + "namespace": "default", + }, + }, + }, + }} + + if _, err := s.dyn.Resource(packageGVR).Namespace("default").Create(context.Background(), pkg, metav1.CreateOptions{}); err != nil { + t.Fatalf("create package: %v", err) + } + if _, err := s.dyn.Resource(functionGVR).Namespace("default").Create(context.Background(), fn, metav1.CreateOptions{}); err != nil { + t.Fatalf("create function: %v", err) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/functions/fn-go-acc", nil) + getRec := httptest.NewRecorder() + s.handleGetFunction(getRec, getReq, "fn-go-acc") + if getRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var out map[string]any + if err := json.Unmarshal(getRec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode get response: %v", err) + } + + code, _ := out["code"].(string) + if !strings.Contains(code, "func Handler") { + t.Fatalf("expected source code from spec.source.literal, got %q", code) + } +} + func TestInvokeFunctionWithJWTAuth(t *testing.T) { // Mock router: /auth/login returns JWT, /inv-fn returns hello var gotAuth string diff --git a/examples/auto-funcs/code/ok/main.py b/examples/auto-funcs/code/ok/main.py index 962d505..1662884 100644 --- a/examples/auto-funcs/code/ok/main.py +++ b/examples/auto-funcs/code/ok/main.py @@ -1,2 +1,2 @@ def main(): - return "ok-auto-func-UPDATED-v2" + return "ok-auto-func-UPDATED-v3-with-comment" diff --git a/examples/go-hello/code/main.go b/examples/go-hello/code/main.go deleted file mode 100644 index 1b6f3fa..0000000 --- a/examples/go-hello/code/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "fmt" - "net/http" -) - -func Handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - fmt.Fprintf(w, "Hello from Go in Fission") -} diff --git a/examples/go-hello/main.tf b/examples/go-hello/main.tf deleted file mode 100644 index ca9faeb..0000000 --- a/examples/go-hello/main.tf +++ /dev/null @@ -1,39 +0,0 @@ -terraform { - required_providers { - fission = { - source = "nail/fission" - version = "~> 0.1.0" - } - } -} - -provider "fission" { - kubeconfig_path = "/home/naeel/.kube/config" - namespace = "default" -} - -resource "fission_environment" "go" { - name = "tf-go-hello-env" - image = "ghcr.io/fission/go-env" - version = 3 -} - -resource "fission_package" "pkg" { - name = "tf-go-hello-pkg" - environment = fission_environment.go.name - source_dir = "${path.module}/code" -} - -resource "fission_function" "fn" { - name = "tf-go-hello-fn" - environment = fission_environment.go.name - package_name = fission_package.pkg.name - entrypoint = "main.Handler" -} - -resource "fission_http_trigger" "route" { - name = "tf-go-hello-route" - function = fission_function.fn.name - url = "/go-hello" - methods = ["GET"] -}