feat: scaffold fission console with list and function CRUD/invoke APIs

This commit is contained in:
Naeel
2026-04-15 07:13:48 +03:00
parent 8c3385693f
commit 7e2278371a
4 changed files with 743 additions and 0 deletions
+36
View File
@@ -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
View File
@@ -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
}
+13
View File
@@ -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))
}
+186
View File
@@ -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>