diff --git a/console/go.mod b/console/go.mod new file mode 100644 index 0000000..62c12dd --- /dev/null +++ b/console/go.mod @@ -0,0 +1,36 @@ +module fission-console + +go 1.25.0 + +require ( + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/console/main.go b/console/main.go new file mode 100644 index 0000000..48afef6 --- /dev/null +++ b/console/main.go @@ -0,0 +1,508 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "fission-console/ui" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +var ( + environmentGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"} + packageGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"} + functionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"} + httpTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"} + timeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"} +) + +type server struct { + dyn dynamic.Interface + ns string + routerURL string + http *http.Client +} + +type createFunctionRequest struct { + Name string `json:"name"` + Environment string `json:"environment"` + Code string `json:"code"` + Entrypoint string `json:"entrypoint"` + Route string `json:"route"` + Methods []string `json:"methods"` +} + +type updateCodeRequest struct { + Code string `json:"code"` +} + +func main() { + kubeconfig := strings.TrimSpace(os.Getenv("KUBECONFIG")) + namespace := envDefault("FISSION_NAMESPACE", "default") + routerURL := strings.TrimRight(envDefault("FISSION_ROUTER_URL", "http://router.fission.svc.cluster.local"), "/") + port := envDefault("PORT", "8090") + + cfg, err := buildConfig(kubeconfig) + if err != nil { + log.Fatalf("build kube config: %v", err) + } + + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + log.Fatalf("create dynamic client: %v", err) + } + + s := &server{ + dyn: dyn, + ns: namespace, + routerURL: routerURL, + http: &http.Client{Timeout: 30 * time.Second}, + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + + uiHandler := ui.Handler() + mux.Handle("/console", uiHandler) + mux.Handle("/console/", uiHandler) + + mux.HandleFunc("/api/environments", s.handleList(environmentGVR)) + mux.HandleFunc("/api/packages", s.handleList(packageGVR)) + mux.HandleFunc("/api/functions", s.handleFunctionsRoot) + mux.HandleFunc("/api/functions/", s.handleFunctionsAction) + mux.HandleFunc("/api/httptriggers", s.handleList(httpTrigGVR)) + mux.HandleFunc("/api/timetriggers", s.handleList(timeTrigGVR)) + + httpServer := &http.Server{ + Addr: ":" + port, + Handler: withCORS(logRequests(mux)), + ReadHeaderTimeout: 10 * time.Second, + } + + log.Printf("fission-console listening on :%s (namespace=%s)", port, namespace) + log.Fatal(httpServer.ListenAndServe()) +} + +func (s *server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleList(functionGVR)(w, r) + case http.MethodPost: + s.handleCreateFunction(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/functions/") + path = strings.Trim(path, "/") + if path == "" { + http.NotFound(w, r) + return + } + + parts := strings.Split(path, "/") + name := strings.TrimSpace(parts[0]) + if name == "" { + writeJSONError(w, http.StatusBadRequest, "function name is required") + return + } + + if len(parts) == 1 { + switch r.Method { + case http.MethodGet: + s.handleGetFunction(w, r, name) + case http.MethodDelete: + s.handleDeleteFunction(w, r, name) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + return + } + + if len(parts) == 2 && parts[1] == "code" && r.Method == http.MethodPut { + s.handleUpdateFunctionCode(w, r, name) + return + } + + if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost { + s.handleInvokeFunction(w, r, name) + return + } + + http.NotFound(w, r) +} + +func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { + var req createFunctionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Environment = strings.TrimSpace(req.Environment) + req.Code = strings.TrimSpace(req.Code) + req.Entrypoint = strings.TrimSpace(req.Entrypoint) + req.Route = strings.TrimSpace(req.Route) + + if req.Name == "" || req.Environment == "" || req.Code == "" { + writeJSONError(w, http.StatusBadRequest, "name, environment and code are required") + return + } + if req.Entrypoint == "" { + req.Entrypoint = "main.main" + } + if req.Route == "" { + req.Route = "/" + req.Name + } + if len(req.Methods) == 0 { + req.Methods = []string{"GET"} + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + if _, err := s.dyn.Resource(environmentGVR).Namespace(s.ns).Get(ctx, req.Environment, metav1.GetOptions{}); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", req.Environment, err)) + return + } + + pkgName := req.Name + "-pkg" + triggerName := req.Name + "-route" + + literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) + pkg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{ + "name": pkgName, + "namespace": s.ns, + }, + "spec": map[string]any{ + "deployment": map[string]any{ + "type": "literal", + "literal": literal, + }, + "environment": map[string]any{ + "name": req.Environment, + "namespace": s.ns, + }, + "source": map[string]any{}, + }, + }} + + if _, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err)) + return + } + + fn := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": req.Name, + "namespace": s.ns, + }, + "spec": map[string]any{ + "environment": map[string]any{ + "name": req.Environment, + "namespace": s.ns, + }, + "InvokeStrategy": map[string]any{ + "ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"}, + "StrategyType": "execution", + }, + "package": map[string]any{ + "packageref": map[string]any{"name": pkgName, "namespace": s.ns}, + "functionName": req.Entrypoint, + }, + }, + }} + + if _, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil { + _ = s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err)) + return + } + + httpTrigger := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "HTTPTrigger", + "metadata": map[string]any{ + "name": triggerName, + "namespace": s.ns, + }, + "spec": map[string]any{ + "relativeurl": req.Route, + "methods": req.Methods, + "createingress": true, + "functionref": map[string]any{ + "type": "name", + "name": req.Name, + }, + }, + }} + + if _, err := s.dyn.Resource(httpTrigGVR).Namespace(s.ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil { + _ = s.dyn.Resource(functionGVR).Namespace(s.ns).Delete(ctx, req.Name, metav1.DeleteOptions{}) + _ = s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err)) + return + } + + writeAnyJSON(w, http.StatusCreated, map[string]any{ + "name": req.Name, + "package": pkgName, + "httptrigger": triggerName, + "route": req.Route, + }) +} + +func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + fn, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) + return + } + + writeAnyJSON(w, http.StatusOK, fn.Object) +} + +func (s *server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) { + var req updateCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) + return + } + req.Code = strings.TrimSpace(req.Code) + if req.Code == "" { + writeJSONError(w, http.StatusBadRequest, "code is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + fn, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) + return + } + + pkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + if pkgName == "" { + writeJSONError(w, http.StatusBadGateway, "function has no package reference") + return + } + + pkg, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, pkgName, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get package %q: %v", pkgName, err)) + return + } + + literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) + if err := unstructured.SetNestedField(pkg.Object, literal, "spec", "deployment", "literal"); err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set package literal: %v", err)) + return + } + + if _, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Update(ctx, pkg, metav1.UpdateOptions{}); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update package %q: %v", pkgName, err)) + return + } + + writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "package": pkgName}) +} + +func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err)) + return + } + if len(bytes.TrimSpace(bodyBytes)) == 0 { + bodyBytes = []byte("{}") + } + + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + + invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name) + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, invokeURL, bytes.NewReader(bodyBytes)) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err)) + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.http.Do(req) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err)) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + writeAnyJSON(w, http.StatusOK, map[string]any{ + "status": resp.StatusCode, + "latency_ms": time.Since(start).Milliseconds(), + "invoke_url": invokeURL, + "response_raw": string(respBody), + }) +} + +func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + var pkgName string + fn, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Get(ctx, name, metav1.GetOptions{}) + if err == nil { + pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + } + + triggers, err := s.dyn.Resource(httpTrigGVR).Namespace(s.ns).List(ctx, metav1.ListOptions{}) + if err == nil { + for _, trig := range triggers.Items { + refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if refName == name { + _ = s.dyn.Resource(httpTrigGVR).Namespace(s.ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{}) + } + } + } + + if err := s.dyn.Resource(functionGVR).Namespace(s.ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err)) + return + } + + if pkgName != "" { + if err := s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err)) + return + } + } + + writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName}) +} + +func buildConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig != "" { + cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err == nil { + return cfg, nil + } + return nil, fmt.Errorf("kubeconfig %s: %w", kubeconfig, err) + } + + cfg, err := rest.InClusterConfig() + if err == nil { + return cfg, nil + } + + loadingRules := &clientcmd.ClientConfigLoadingRules{} + clientCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + return clientCfg.ClientConfig() +} + +func (s *server) handleList(gvr schema.GroupVersionResource) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + list, err := s.dyn.Resource(gvr).Namespace(s.ns).List(ctx, metav1.ListOptions{}) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list %s: %v", gvr.Resource, err)) + return + } + + writeJSON(w, http.StatusOK, list.Items) + } +} + +func writeJSON(w http.ResponseWriter, status int, data []unstructured.Unstructured) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeAnyJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"error": msg}) +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.URL.Path) + next.ServeHTTP(w, r) + }) +} + +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func envDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} diff --git a/console/ui/embed.go b/console/ui/embed.go new file mode 100644 index 0000000..20ef7f6 --- /dev/null +++ b/console/ui/embed.go @@ -0,0 +1,13 @@ +package ui + +import ( + "embed" + "net/http" +) + +//go:embed index.html +var content embed.FS + +func Handler() http.Handler { + return http.FileServer(http.FS(content)) +} diff --git a/console/ui/index.html b/console/ui/index.html new file mode 100644 index 0000000..41c95d9 --- /dev/null +++ b/console/ui/index.html @@ -0,0 +1,186 @@ + + + + + + Fission Console + + + + + +
+
+
Environments
-
+
Packages
-
+
Functions
-
+
HTTP Triggers
-
+
Time Triggers
-
+
+ +
+
Functions (MVP list)
+ + + + + +
NameEnvironmentPackage
+
Initial MVP. Next step: create/edit/invoke/delete.
+
+
+ + + +