metis/pkg/service/server.go

246 lines
6.6 KiB
Go

package service
import (
"encoding/json"
"errors"
"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")
devices, err := a.RefreshDevices(host)
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
}
values := requestValues(r)
node := values["node"]
job, err := a.Build(node)
if err != nil {
http.Error(w, err.Error(), statusForJobError(err))
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
}
values := requestValues(r)
node := values["node"]
host := values["host"]
device := values["device"]
job, err := a.Replace(node, host, device)
if err != nil {
http.Error(w, err.Error(), statusForJobError(err))
return
}
writeJSON(w, http.StatusAccepted, job)
}
func statusForJobError(err error) int {
var conflict *activeNodeJobError
if errors.As(err, &conflict) {
return http.StatusConflict
}
return http.StatusBadRequest
}
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) {
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"))
if len(groups) == 0 {
return userContext{Name: user, Groups: groups}, false
}
for _, group := range groups {
for _, allowed := range a.settings.AllowedGroups {
if normalizeGroupValue(group) == normalizeGroupValue(allowed) {
return userContext{Name: user, Groups: groups}, true
}
}
}
return userContext{Name: user, Groups: groups}, false
}
func firstNonEmptyHeader(r *http.Request, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(r.Header.Get(key)); value != "" {
return value
}
}
return ""
}
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
}
func normalizeGroupValue(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
return strings.TrimPrefix(value, "/")
}
func requestValues(r *http.Request) map[string]string {
values := map[string]string{}
if err := r.ParseForm(); err == nil {
for key, rawValues := range r.Form {
for _, raw := range rawValues {
if value := strings.TrimSpace(raw); value != "" {
values[key] = value
break
}
}
}
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
for key, raw := range payload {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
values[key] = strings.TrimSpace(value)
}
}
}
return values
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}