2026-03-31 14:52:50 -03:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-04-19 21:54:51 -03:00
|
|
|
"errors"
|
2026-03-31 14:52:50 -03:00
|
|
|
"html/template"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type userContext struct {
|
|
|
|
|
Name string
|
|
|
|
|
Groups []string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type pageData struct {
|
|
|
|
|
State PageState
|
|
|
|
|
AllowedGroups []string
|
|
|
|
|
DefaultMessage string
|
|
|
|
|
BootJSON template.JS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handler returns the Metis HTTP handler.
|
|
|
|
|
func (a *App) Handler() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("/healthz", a.handleHealth)
|
|
|
|
|
mux.HandleFunc("/metrics", a.handleMetrics)
|
|
|
|
|
mux.HandleFunc("/internal/sentinel/snapshot", a.handleInternalSnapshot)
|
|
|
|
|
mux.HandleFunc("/internal/sentinel/watch", a.handleInternalWatch)
|
|
|
|
|
mux.HandleFunc("/api/state", a.withUIAuth(a.handleState))
|
|
|
|
|
mux.HandleFunc("/api/devices", a.withUIAuth(a.handleDevices))
|
|
|
|
|
mux.HandleFunc("/api/jobs/build", a.withUIAuth(a.handleBuild))
|
|
|
|
|
mux.HandleFunc("/api/jobs/replace", a.withUIAuth(a.handleReplace))
|
|
|
|
|
mux.HandleFunc("/api/sentinel/watch", a.withUIAuth(a.handleWatch))
|
|
|
|
|
mux.HandleFunc("/", a.withUIAuth(a.handleIndex))
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "metis"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleMetrics(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
|
|
|
a.metrics.Render(w)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleInternalSnapshot(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var record SnapshotRecord
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&record); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := a.StoreSnapshot(record); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleInternalWatch(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
event, err := a.WatchSentinel()
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.metrics.RecordWatch("error")
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleState(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
host := r.URL.Query().Get("host")
|
|
|
|
|
writeJSON(w, http.StatusOK, a.State(host))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
host := r.URL.Query().Get("host")
|
2026-04-01 13:13:09 -03:00
|
|
|
devices, err := a.RefreshDevices(host)
|
2026-03-31 14:52:50 -03:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleBuild(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-31 20:53:59 -03:00
|
|
|
values := requestValues(r)
|
|
|
|
|
node := values["node"]
|
2026-03-31 14:52:50 -03:00
|
|
|
job, err := a.Build(node)
|
|
|
|
|
if err != nil {
|
2026-04-19 21:54:51 -03:00
|
|
|
http.Error(w, err.Error(), statusForJobError(err))
|
2026-03-31 14:52:50 -03:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusAccepted, job)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleReplace(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-31 20:53:59 -03:00
|
|
|
values := requestValues(r)
|
|
|
|
|
node := values["node"]
|
|
|
|
|
host := values["host"]
|
|
|
|
|
device := values["device"]
|
2026-03-31 14:52:50 -03:00
|
|
|
job, err := a.Replace(node, host, device)
|
|
|
|
|
if err != nil {
|
2026-04-19 21:54:51 -03:00
|
|
|
http.Error(w, err.Error(), statusForJobError(err))
|
2026-03-31 14:52:50 -03:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusAccepted, job)
|
|
|
|
|
}
|
2026-04-19 21:54:51 -03:00
|
|
|
|
|
|
|
|
func statusForJobError(err error) int {
|
|
|
|
|
var conflict *activeNodeJobError
|
|
|
|
|
if errors.As(err, &conflict) {
|
|
|
|
|
return http.StatusConflict
|
|
|
|
|
}
|
|
|
|
|
return http.StatusBadRequest
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
|
|
|
|
|
func (a *App) handleWatch(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
event, err := a.WatchSentinel()
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.metrics.RecordWatch("error")
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
state := a.State(a.settings.DefaultFlashHost)
|
|
|
|
|
payload, _ := json.Marshal(state)
|
|
|
|
|
data := pageData{
|
|
|
|
|
State: state,
|
|
|
|
|
AllowedGroups: append([]string{}, a.settings.AllowedGroups...),
|
|
|
|
|
BootJSON: template.JS(payload),
|
|
|
|
|
}
|
|
|
|
|
_ = metisPage.Execute(w, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) withUIAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user, ok := a.authorize(r)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if user.Name != "" {
|
|
|
|
|
w.Header().Set("X-Metis-User", user.Name)
|
|
|
|
|
}
|
|
|
|
|
next(w, r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) authorize(r *http.Request) (userContext, bool) {
|
2026-03-31 18:03:14 -03:00
|
|
|
user := firstNonEmptyHeader(r, "X-Auth-Request-User", "X-Forwarded-User", "X-Auth-Request-Email", "X-Forwarded-Email")
|
|
|
|
|
groups := splitHeaderList(firstNonEmptyHeader(r, "X-Auth-Request-Groups", "X-Forwarded-Groups"))
|
2026-03-31 18:46:13 -03:00
|
|
|
if len(groups) == 0 {
|
|
|
|
|
return userContext{Name: user, Groups: groups}, false
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
for _, group := range groups {
|
|
|
|
|
for _, allowed := range a.settings.AllowedGroups {
|
2026-03-31 18:03:14 -03:00
|
|
|
if normalizeGroupValue(group) == normalizeGroupValue(allowed) {
|
2026-03-31 14:52:50 -03:00
|
|
|
return userContext{Name: user, Groups: groups}, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return userContext{Name: user, Groups: groups}, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:03:14 -03:00
|
|
|
func firstNonEmptyHeader(r *http.Request, keys ...string) string {
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
if value := strings.TrimSpace(r.Header.Get(key)); value != "" {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
func splitHeaderList(raw string) []string {
|
|
|
|
|
if strings.TrimSpace(raw) == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Split(raw, ",")
|
|
|
|
|
out := make([]string, 0, len(parts))
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
part = strings.TrimSpace(part)
|
|
|
|
|
if part != "" {
|
|
|
|
|
out = append(out, part)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:03:14 -03:00
|
|
|
func normalizeGroupValue(raw string) string {
|
|
|
|
|
value := strings.ToLower(strings.TrimSpace(raw))
|
|
|
|
|
return strings.TrimPrefix(value, "/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:53:59 -03:00
|
|
|
func requestValues(r *http.Request) map[string]string {
|
|
|
|
|
values := map[string]string{}
|
2026-03-31 14:52:50 -03:00
|
|
|
if err := r.ParseForm(); err == nil {
|
2026-03-31 20:53:59 -03:00
|
|
|
for key, rawValues := range r.Form {
|
|
|
|
|
for _, raw := range rawValues {
|
|
|
|
|
if value := strings.TrimSpace(raw); value != "" {
|
|
|
|
|
values[key] = value
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var payload map[string]any
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
|
2026-03-31 20:53:59 -03:00
|
|
|
for key, raw := range payload {
|
|
|
|
|
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
|
|
|
|
|
values[key] = strings.TrimSpace(value)
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 20:53:59 -03:00
|
|
|
return values
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
|
|
|
}
|