feat: scaffold fission console with list and function CRUD/invoke APIs
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
+508
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fission Console</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-page: #001120;
|
||||||
|
--bg-surface: #001929;
|
||||||
|
--bg-navbar: #001c34;
|
||||||
|
--border: #0b2d50;
|
||||||
|
--accent: #1a7fd4;
|
||||||
|
--text-primary: #e2ecf6;
|
||||||
|
--text-secondary: #6b8eaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--bg-navbar);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #0b2a48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="navbar">
|
||||||
|
<div class="title">sless / Fission Console</div>
|
||||||
|
<button class="btn" onclick="reloadAll()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="k">Environments</div><div id="env-count" class="v">-</div></div>
|
||||||
|
<div class="card"><div class="k">Packages</div><div id="pkg-count" class="v">-</div></div>
|
||||||
|
<div class="card"><div class="k">Functions</div><div id="fn-count" class="v">-</div></div>
|
||||||
|
<div class="card"><div class="k">HTTP Triggers</div><div id="http-count" class="v">-</div></div>
|
||||||
|
<div class="card"><div class="k">Time Triggers</div><div id="time-count" class="v">-</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<div style="font-weight:600;">Functions (MVP list)</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Environment</th><th>Package</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="fn-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="hint">Initial MVP. Next step: create/edit/invoke/delete.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function getJSON(url) {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(url + ': ' + r.status);
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
document.getElementById(id).textContent = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAll() {
|
||||||
|
try {
|
||||||
|
const [envs, pkgs, fns, http, time] = await Promise.all([
|
||||||
|
getJSON('/api/environments'),
|
||||||
|
getJSON('/api/packages'),
|
||||||
|
getJSON('/api/functions'),
|
||||||
|
getJSON('/api/httptriggers'),
|
||||||
|
getJSON('/api/timetriggers')
|
||||||
|
]);
|
||||||
|
|
||||||
|
setText('env-count', envs.length || 0);
|
||||||
|
setText('pkg-count', pkgs.length || 0);
|
||||||
|
setText('fn-count', fns.length || 0);
|
||||||
|
setText('http-count', http.length || 0);
|
||||||
|
setText('time-count', time.length || 0);
|
||||||
|
|
||||||
|
const rows = (fns || []).map(function (f) {
|
||||||
|
const spec = f.spec || {};
|
||||||
|
const env = (spec.environment && spec.environment.name) || '-';
|
||||||
|
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
||||||
|
const name = (f.metadata && f.metadata.name) || '-';
|
||||||
|
return '<tr><td>' + name + '</td><td>' + env + '</td><td>' + pkg + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">No functions</td></tr>';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="3">Load error: ' + e.message + '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user