feat: web-console — HTML UI + source viewer + trigger toggle
- operator: GET /v1/namespaces/{ns}/functions/{name}/source
reads build context tar.gz from S3, strips Dockerfile, returns JSON files
- funcs-service v0.2.0:
- Accept: text/html → dark-themed HTML console with accordion cards
- GET /funcs/{ns}/source/{fn} → proxy to operator (service token auth)
- PATCH /funcs/{ns}/triggers/{name} → proxy enable/disable (only enabled field)
- curl (no text/html Accept) → plain text as before (backward compat)
- highlight.js syntax highlighting per file extension
- operator v0.1.34, funcs-service v0.2.0
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
# 2026-03-18
|
||||
# 2026-04-25 (добавлен index.html — встраивается через go:embed)
|
||||
# Dockerfile для sless-funcs-service.
|
||||
# Многоэтапная сборка: Go → alpine (минимальный образ).
|
||||
# Сервис не нуждается во внешних зависимостях — только stdlib.
|
||||
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY go.mod main.go ./
|
||||
COPY go.mod main.go index.html ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>sless console</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #161b22;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #58a6ff;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.logo-slash {
|
||||
color: #484f58;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.ns-badge {
|
||||
background: #1c2128;
|
||||
border: 1px solid #30363d;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
margin-left: auto;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 24px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: #8b949e;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.fn-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.fn-card:hover {
|
||||
border-color: #484f58;
|
||||
}
|
||||
|
||||
.fn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.fn-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.fn-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-runtime {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.badge-Ready {
|
||||
background: #1a4731;
|
||||
border: 1px solid #1f6132;
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.badge-Building {
|
||||
background: #382a0a;
|
||||
border: 1px solid #5a4212;
|
||||
color: #d29922;
|
||||
}
|
||||
|
||||
.badge-Pending {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.badge-Failed {
|
||||
background: #3d1a1a;
|
||||
border: 1px solid #6b1a1a;
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.fn-url {
|
||||
font-size: 0.76rem;
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 280px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.fn-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fn-cron {
|
||||
font-size: 0.76rem;
|
||||
color: #8b949e;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.fn-msg {
|
||||
font-size: 0.73rem;
|
||||
color: #f85149;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 0 0;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.trigger-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
transition: opacity 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.trigger-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.trigger-btn.state-on {
|
||||
background: #21262d;
|
||||
border-color: #1f6132;
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.trigger-btn.state-on:hover:not(:disabled) {
|
||||
background: #1a4731;
|
||||
}
|
||||
|
||||
.trigger-btn.state-off {
|
||||
background: #21262d;
|
||||
border-color: #6b1a1a;
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.trigger-btn.state-off:hover:not(:disabled) {
|
||||
background: #3d1a1a;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #8b949e;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.65rem;
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
.fn-body {
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.src-msg {
|
||||
padding: 14px 16px;
|
||||
color: #8b949e;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.file-block {
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.file-block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
padding: 6px 16px;
|
||||
background: #0d1117;
|
||||
font-family: monospace;
|
||||
font-size: 0.76rem;
|
||||
color: #8b949e;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.file-block pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-block pre code.hljs {
|
||||
border-radius: 0 !important;
|
||||
font-size: 0.78rem !important;
|
||||
padding: 14px 16px !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: #484f58;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">sless</span>
|
||||
<span class="logo-slash">/</span>
|
||||
<span class="ns-badge" id="ns-label">…</span>
|
||||
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
|
||||
</header>
|
||||
<main>
|
||||
<p class="summary" id="summary"></p>
|
||||
<div id="fn-list"></div>
|
||||
</main>
|
||||
|
||||
<script id="fndata" type="application/json">__PAGE_DATA__</script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var raw = document.getElementById('fndata').textContent;
|
||||
var data = JSON.parse(raw);
|
||||
var ns = data.namespace;
|
||||
var extURL = data.externalURL || '';
|
||||
var fns = data.functions || [];
|
||||
var byFn = data.triggersByFn || {};
|
||||
|
||||
document.getElementById('ns-label').textContent = ns;
|
||||
document.getElementById('summary').textContent = 'Функций: ' + fns.length;
|
||||
|
||||
var list = document.getElementById('fn-list');
|
||||
if (fns.length === 0) {
|
||||
var empty = $el('div', 'empty-state');
|
||||
empty.textContent = 'Нет функций в этом namespace';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
for (var i = 0; i < fns.length; i++) {
|
||||
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
|
||||
}
|
||||
|
||||
function buildCard(fn, triggers) {
|
||||
var card = $el('div', 'fn-card');
|
||||
var hdr = $el('div', 'fn-header');
|
||||
var body = $el('div', 'fn-body');
|
||||
body.style.display = 'none';
|
||||
|
||||
$txt(hdr, 'span', 'fn-name', fn.name);
|
||||
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
|
||||
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase);
|
||||
|
||||
for (var t = 0; t < triggers.length; t++) {
|
||||
var tr = triggers[t];
|
||||
if (tr.type === 'http') {
|
||||
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
|
||||
if (url) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = url;
|
||||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
hdr.appendChild(a);
|
||||
}
|
||||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
||||
} else if (tr.type === 'cron') {
|
||||
$txt(hdr, 'span', 'fn-cron', 'cron: ' + tr.schedule);
|
||||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
||||
}
|
||||
}
|
||||
|
||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||||
hdr.appendChild($el('span', 'spacer'));
|
||||
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
|
||||
|
||||
var loaded = false;
|
||||
hdr.addEventListener('click', function () {
|
||||
if (body.style.display === 'none') {
|
||||
body.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
if (!loaded) {
|
||||
loaded = true;
|
||||
loadSource(body, ns, fn.name);
|
||||
}
|
||||
} else {
|
||||
body.style.display = 'none';
|
||||
icon.style.transform = '';
|
||||
}
|
||||
});
|
||||
|
||||
card.appendChild(hdr);
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
function makeTriggerBtn(tr, ns) {
|
||||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
||||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
||||
btn.title = tr.name;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
btn.disabled = true;
|
||||
var newVal = !tr.enabled;
|
||||
fetch('/funcs/' + ns + '/triggers/' + tr.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: newVal })
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
tr.enabled = newVal;
|
||||
btn.className = 'trigger-btn ' + (newVal ? 'state-on' : 'state-off');
|
||||
btn.textContent = newVal ? '■ стоп' : '▶ запуск';
|
||||
} else {
|
||||
return r.text().then(function (t) { alert('Ошибка: ' + t); });
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert('Сетевая ошибка: ' + err);
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function loadSource(body, ns, fnName) {
|
||||
var msg = $el('div', 'src-msg');
|
||||
msg.textContent = 'Загрузка кода…';
|
||||
body.appendChild(msg);
|
||||
|
||||
fetch('/funcs/' + ns + '/source/' + fnName)
|
||||
.then(function (r) {
|
||||
body.removeChild(msg);
|
||||
if (!r.ok) {
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка ' + r.status;
|
||||
body.appendChild(e);
|
||||
return;
|
||||
}
|
||||
return r.json().then(function (files) {
|
||||
var shown = files.filter(function (f) { return !f.binary; });
|
||||
if (shown.length === 0) {
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Код не загружен';
|
||||
body.appendChild(e);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < shown.length; i++) {
|
||||
var f = shown[i];
|
||||
var block = $el('div', 'file-block');
|
||||
var fhdr = $el('div', 'file-header');
|
||||
fhdr.textContent = f.name;
|
||||
var pre = document.createElement('pre');
|
||||
var code = document.createElement('code');
|
||||
code.className = 'language-' + detectLang(f.name);
|
||||
code.textContent = f.content;
|
||||
pre.appendChild(code);
|
||||
block.appendChild(fhdr);
|
||||
block.appendChild(pre);
|
||||
body.appendChild(block);
|
||||
}
|
||||
hljs.highlightAll();
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '';
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка: ' + err;
|
||||
body.appendChild(e);
|
||||
});
|
||||
}
|
||||
|
||||
function detectLang(name) {
|
||||
var ext = name.split('.').pop().toLowerCase();
|
||||
var m = {
|
||||
py: 'python', js: 'javascript', ts: 'typescript', go: 'go',
|
||||
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml',
|
||||
json: 'json', md: 'markdown', txt: 'plaintext', toml: 'toml',
|
||||
rs: 'rust', rb: 'ruby', java: 'java', cpp: 'cpp', c: 'c',
|
||||
cs: 'csharp', php: 'php', html: 'html', css: 'css'
|
||||
};
|
||||
return m[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
function $el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
||||
function $txt(p, tag, cls, text) { var e = $el(tag, cls); e.textContent = text; p.appendChild(e); return e; }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+337
-138
@@ -1,25 +1,29 @@
|
||||
// 2026-03-18
|
||||
// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers)
|
||||
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
||||
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
||||
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
||||
//
|
||||
// Логика:
|
||||
// 1. Принимает Authorization: Bearer {jwt} от пользователя
|
||||
// 2. Извлекает sub из JWT (без проверки подписи — trusted perimeter за Ingress)
|
||||
// 3. Вычисляет namespace = SHA256(sub)[:8] hex (та же логика что в операторе)
|
||||
// 4. Вызывает внутренний API оператора: GET /v1/namespaces/{ns}/functions + /triggers
|
||||
// 5. Возвращает plain text — человекочитаемый список
|
||||
// Маршруты:
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
|
||||
// GET /health — liveness/readiness probe
|
||||
//
|
||||
// Env vars:
|
||||
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
||||
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
|
||||
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
|
||||
// SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/<namespace> без токена юзера)
|
||||
// PORT — порт сервера (default: 8090)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -27,10 +31,14 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var htmlPageTemplate string
|
||||
|
||||
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
|
||||
type fnResponse struct {
|
||||
Name string `json:"name"`
|
||||
@@ -52,11 +60,22 @@ type trResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// pageData — структура данных для HTML-шаблона web-консоли.
|
||||
type pageData struct {
|
||||
Namespace string `json:"namespace"`
|
||||
ExternalURL string `json:"externalURL"`
|
||||
Functions []fnResponse `json:"functions"`
|
||||
TriggersByFn map[string][]trResponse `json:"triggersByFn"`
|
||||
}
|
||||
|
||||
// nsRegex — базовая валидация kubernetes namespace (ловеркейс + hyphens).
|
||||
// Защита от path traversal при подстановке в URL запросов к оператору.
|
||||
var nsRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
func main() {
|
||||
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
|
||||
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
|
||||
port := env("PORT", "8090")
|
||||
// serviceToken — токен сервиса для запросов к оператору по /funcs/<namespace> (без токена пользователя)
|
||||
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
|
||||
|
||||
exclude := map[string]bool{}
|
||||
@@ -66,155 +85,335 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
http.HandleFunc("/funcs", handler(operatorURL, externalURL, serviceToken, exclude))
|
||||
// /health — для liveness/readiness probe без Bearer токена
|
||||
// /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header)
|
||||
http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude))
|
||||
// /funcs/ — namespace в URL пути + sub-paths (source/triggers proxy)
|
||||
http.HandleFunc("/funcs/", handleFuncsNS(operatorURL, externalURL, serviceToken, exclude))
|
||||
// /health — для liveness/readiness probe (без auth)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
// /funcs/<namespace> — без токена, namespace виден в URL всех функций
|
||||
http.HandleFunc("/funcs/", handler(operatorURL, externalURL, serviceToken, exclude))
|
||||
|
||||
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
}
|
||||
|
||||
func handler(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
|
||||
// handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header).
|
||||
func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var namespace, authHeader string
|
||||
|
||||
// Вариант 1: /funcs/<namespace> — namespace прямо в URL, токен не нужен.
|
||||
// Namespace виден в URL каждой функции — не является секретом.
|
||||
if strings.HasPrefix(r.URL.Path, "/funcs/") {
|
||||
namespace = strings.TrimPrefix(r.URL.Path, "/funcs/")
|
||||
namespace = strings.Trim(namespace, "/")
|
||||
if namespace == "" {
|
||||
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Используем serviceToken сервиса, если задан
|
||||
if serviceToken != "" {
|
||||
authHeader = "Bearer " + serviceToken
|
||||
}
|
||||
// authHeader может остаться пустым — оператор ответит 401 если не настроен
|
||||
} else {
|
||||
// Вариант 2: ?token=<jwt> — токен как query param (можно bookmarkнуть URL)
|
||||
token := r.URL.Query().Get("token")
|
||||
// Вариант 3: Authorization: Bearer <jwt> — стандартный заголовок
|
||||
if token == "" {
|
||||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
if token == "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, "Укажите namespace в URL или передайте токен:\n\n")
|
||||
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
|
||||
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
|
||||
return
|
||||
}
|
||||
sub, err := subFromJWT(token)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
namespace = namespaceFromSub(sub)
|
||||
authHeader = "Bearer " + token
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
|
||||
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+namespace+"/functions", authHeader)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
|
||||
if token == "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintln(w, "Укажите namespace в URL или передайте токен:")
|
||||
fmt.Fprintln(w, "")
|
||||
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
|
||||
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
|
||||
return
|
||||
}
|
||||
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+namespace+"/triggers", authHeader)
|
||||
sub, err := subFromJWT(token)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
|
||||
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Индекс триггеров по имени функции
|
||||
trigIdx := map[string][]trResponse{}
|
||||
for _, tr := range trs {
|
||||
trigIdx[tr.FunctionRef] = append(trigIdx[tr.FunctionRef], tr)
|
||||
}
|
||||
|
||||
// Фильтрация и сортировка: активные вверх, потом по имени
|
||||
type entry struct {
|
||||
fn fnResponse
|
||||
httpT []trResponse
|
||||
cronT []trResponse
|
||||
isActive bool
|
||||
}
|
||||
var items []entry
|
||||
for _, fn := range fns {
|
||||
if exclude[fn.Name] {
|
||||
continue
|
||||
}
|
||||
var httpT, cronT []trResponse
|
||||
isActive := false
|
||||
for _, tr := range trigIdx[fn.Name] {
|
||||
switch tr.Type {
|
||||
case "http":
|
||||
httpT = append(httpT, tr)
|
||||
case "cron":
|
||||
cronT = append(cronT, tr)
|
||||
}
|
||||
if tr.Enabled && tr.Active {
|
||||
isActive = true
|
||||
}
|
||||
}
|
||||
items = append(items, entry{fn, httpT, cronT, isActive})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].isActive != items[j].isActive {
|
||||
return items[i].isActive
|
||||
}
|
||||
return items[i].fn.Name < items[j].fn.Name
|
||||
})
|
||||
|
||||
sep := strings.Repeat("─", 52)
|
||||
var sb strings.Builder
|
||||
for _, it := range items {
|
||||
fn := it.fn
|
||||
sb.WriteString(sep + "\n")
|
||||
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
|
||||
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
|
||||
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
|
||||
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
|
||||
if it.isActive {
|
||||
sb.WriteString(" active: да\n")
|
||||
} else {
|
||||
sb.WriteString(" active: нет\n")
|
||||
}
|
||||
if len(it.httpT) > 0 {
|
||||
url := it.httpT[0].URL
|
||||
if externalURL != "" {
|
||||
url = externalURL + "/fn/" + namespace + "/" + fn.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
|
||||
}
|
||||
if len(it.cronT) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
|
||||
}
|
||||
if fn.CreatedAt != "" {
|
||||
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
|
||||
}
|
||||
if fn.LastBuiltAt != "" {
|
||||
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
|
||||
}
|
||||
if fn.Message != "" {
|
||||
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
|
||||
}
|
||||
}
|
||||
sb.WriteString(sep + "\n")
|
||||
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", namespace, len(items)))
|
||||
sb.WriteString(sep + "\n")
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprint(w, sb.String())
|
||||
fetchAndRender(w, r, operatorURL, externalURL, "Bearer "+token, namespaceFromSub(sub), exclude)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFuncsNS обрабатывает все запросы /funcs/{ns}[/sub/path].
|
||||
//
|
||||
// Маршруты:
|
||||
//
|
||||
// GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text)
|
||||
// GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции
|
||||
// PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер
|
||||
func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/funcs/")
|
||||
rest = strings.Trim(rest, "/")
|
||||
if rest == "" {
|
||||
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// SplitN до 3 частей: [ns], [ns, subtype] или [ns, subtype, resourceName]
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
ns := parts[0]
|
||||
|
||||
if !nsRegex.MatchString(ns) {
|
||||
http.Error(w, "invalid namespace\n", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) == 3 {
|
||||
switch parts[1] {
|
||||
case "source":
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxySourceGet(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "triggers":
|
||||
if r.Method != http.MethodPatch {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) > 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /funcs/{ns} — листинг с авторизацией через serviceToken
|
||||
authHeader := ""
|
||||
if serviceToken != "" {
|
||||
authHeader = "Bearer " + serviceToken
|
||||
}
|
||||
fetchAndRender(w, r, operatorURL, externalURL, authHeader, ns, exclude)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl).
|
||||
func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) {
|
||||
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if acceptsHTML(r) {
|
||||
serveHTML(w, ns, externalURL, fns, trs, exclude)
|
||||
} else {
|
||||
servePlainText(w, ns, externalURL, fns, trs, exclude)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptsHTML возвращает true если клиент предпочитает HTML (браузер).
|
||||
// curl без -H "Accept: text/html" получит plain text — поведение совместимо с v0.1.x.
|
||||
func acceptsHTML(r *http.Request) bool {
|
||||
return strings.Contains(r.Header.Get("Accept"), "text/html")
|
||||
}
|
||||
|
||||
// serveHTML рендерит web-консоль.
|
||||
// Данные встраиваются как JSON в <script type="application/json"> — изолировано от HTML-контекста.
|
||||
// </script> в данных экранируется как <\/script> (валидный JSON escape).
|
||||
func serveHTML(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
|
||||
trigIdx := buildTriggerIndex(trs)
|
||||
filtered := filterAndSort(fns, trigIdx, exclude)
|
||||
|
||||
pd := pageData{
|
||||
Namespace: ns,
|
||||
ExternalURL: externalURL,
|
||||
Functions: filtered,
|
||||
TriggersByFn: trigIdx,
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(pd)
|
||||
// Предотвращаем преждевременное закрытие тега <script type="application/json">
|
||||
safeJSON := strings.ReplaceAll(string(raw), "</", `<\/`)
|
||||
|
||||
page := strings.ReplaceAll(htmlPageTemplate, "__PAGE_DATA__", safeJSON)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, page)
|
||||
}
|
||||
|
||||
// servePlainText рендерит human-readable листинг для терминала (curl).
|
||||
func servePlainText(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
|
||||
trigIdx := buildTriggerIndex(trs)
|
||||
|
||||
type entry struct {
|
||||
fn fnResponse
|
||||
httpT []trResponse
|
||||
cronT []trResponse
|
||||
}
|
||||
|
||||
var items []entry
|
||||
for _, fn := range fns {
|
||||
if exclude[fn.Name] {
|
||||
continue
|
||||
}
|
||||
var httpT, cronT []trResponse
|
||||
for _, tr := range trigIdx[fn.Name] {
|
||||
switch tr.Type {
|
||||
case "http":
|
||||
httpT = append(httpT, tr)
|
||||
case "cron":
|
||||
cronT = append(cronT, tr)
|
||||
}
|
||||
}
|
||||
items = append(items, entry{fn, httpT, cronT})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
ai := isActive(items[i].fn.Name, trigIdx)
|
||||
aj := isActive(items[j].fn.Name, trigIdx)
|
||||
if ai != aj {
|
||||
return ai
|
||||
}
|
||||
return items[i].fn.Name < items[j].fn.Name
|
||||
})
|
||||
|
||||
sep := strings.Repeat("─", 52)
|
||||
var sb strings.Builder
|
||||
for _, it := range items {
|
||||
fn := it.fn
|
||||
sb.WriteString(sep + "\n")
|
||||
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
|
||||
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
|
||||
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
|
||||
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
|
||||
if isActive(fn.Name, trigIdx) {
|
||||
sb.WriteString(" active: да\n")
|
||||
} else {
|
||||
sb.WriteString(" active: нет\n")
|
||||
}
|
||||
if len(it.httpT) > 0 {
|
||||
url := it.httpT[0].URL
|
||||
if externalURL != "" {
|
||||
url = externalURL + "/fn/" + ns + "/" + fn.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
|
||||
}
|
||||
if len(it.cronT) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
|
||||
}
|
||||
if fn.CreatedAt != "" {
|
||||
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
|
||||
}
|
||||
if fn.LastBuiltAt != "" {
|
||||
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
|
||||
}
|
||||
if fn.Message != "" {
|
||||
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
|
||||
}
|
||||
}
|
||||
sb.WriteString(sep + "\n")
|
||||
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", ns, len(items)))
|
||||
sb.WriteString(sep + "\n")
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprint(w, sb.String())
|
||||
}
|
||||
|
||||
// proxySourceGet проксирует GET /funcs/{ns}/source/{fn} → оператор GET /v1/.../source.
|
||||
// Авторизация — сервисный токен (хранится на сервере, не открывается клиенту).
|
||||
func proxySourceGet(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, funcName string) {
|
||||
if !isValidK8sName(funcName) {
|
||||
http.Error(w, "invalid function name\n", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target := operatorURL + "/v1/namespaces/" + ns + "/functions/" + funcName + "/source"
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error\n", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if serviceToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body) //nolint:errcheck
|
||||
}
|
||||
|
||||
// proxyTriggerPatch проксирует PATCH /funcs/{ns}/triggers/{name} → оператор.
|
||||
// Принимает только поле "enabled" — защита от произвольных изменений с сервисным токеном.
|
||||
func proxyTriggerPatch(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, triggerName string) {
|
||||
if !isValidK8sName(triggerName) {
|
||||
http.Error(w, "invalid trigger name\n", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Читаем только поле enabled — остальные поля отбрасываем.
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil {
|
||||
http.Error(w, `{"error":"required: {\"enabled\": true/false}"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reqBody, _ := json.Marshal(map[string]bool{"enabled": *body.Enabled})
|
||||
target := operatorURL + "/v1/namespaces/" + ns + "/triggers/" + triggerName
|
||||
opReq, err := http.NewRequestWithContext(r.Context(), http.MethodPatch, target, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
http.Error(w, "internal error\n", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
opReq.Header.Set("Content-Type", "application/json")
|
||||
if serviceToken != "" {
|
||||
opReq.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(opReq)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body) //nolint:errcheck
|
||||
}
|
||||
|
||||
// buildTriggerIndex строит индекс триггеров по имени функции (FunctionRef).
|
||||
func buildTriggerIndex(trs []trResponse) map[string][]trResponse {
|
||||
idx := map[string][]trResponse{}
|
||||
for _, tr := range trs {
|
||||
idx[tr.FunctionRef] = append(idx[tr.FunctionRef], tr)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// filterAndSort фильтрует функции (exclude) и сортирует: активные вверх, затем по имени.
|
||||
func filterAndSort(fns []fnResponse, trigIdx map[string][]trResponse, exclude map[string]bool) []fnResponse {
|
||||
result := make([]fnResponse, 0, len(fns))
|
||||
for _, fn := range fns {
|
||||
if !exclude[fn.Name] {
|
||||
result = append(result, fn)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
ai := isActive(result[i].Name, trigIdx)
|
||||
aj := isActive(result[j].Name, trigIdx)
|
||||
if ai != aj {
|
||||
return ai
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// isActive возвращает true если хотя бы один триггер функции включён и активен.
|
||||
func isActive(funcName string, trigIdx map[string][]trResponse) bool {
|
||||
for _, tr := range trigIdx[funcName] {
|
||||
if tr.Enabled && tr.Active {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidK8sName базовая проверка имени k8s-ресурса: нет path traversal символов.
|
||||
func isValidK8sName(s string) bool {
|
||||
return s != "" && !strings.Contains(s, "/") && !strings.Contains(s, "..") && len(s) <= 253
|
||||
}
|
||||
|
||||
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
|
||||
if len(httpT) > 0 {
|
||||
active := "активна"
|
||||
|
||||
Reference in New Issue
Block a user