package service import ( "encoding/json" "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.ListDevices(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 } node := requestValue(r, "node") job, err := a.Build(node) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) 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 } node := requestValue(r, "node") host := requestValue(r, "host") device := requestValue(r, "device") job, err := a.Replace(node, host, device) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusAccepted, job) } 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 := strings.TrimSpace(r.Header.Get("X-Auth-Request-User")) if user == "" { user = strings.TrimSpace(r.Header.Get("X-Forwarded-User")) } if user == "" { return userContext{}, false } groups := splitHeaderList(r.Header.Get("X-Auth-Request-Groups")) for _, allowedUser := range a.settings.AllowedUsers { if allowedUser == user { return userContext{Name: user, Groups: groups}, true } } for _, group := range groups { for _, allowed := range a.settings.AllowedGroups { if group == allowed { return userContext{Name: user, Groups: groups}, true } } } return userContext{Name: user, Groups: groups}, false } 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 requestValue(r *http.Request, key string) string { if err := r.ParseForm(); err == nil { if value := strings.TrimSpace(r.Form.Get(key)); value != "" { return value } } var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { if value, ok := payload[key].(string); ok { return strings.TrimSpace(value) } } return "" } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } var metisPage = template.Must(template.New("metis").Parse(` Metis Control
Atlas Recovery Plane

Metis Control

Build replacement node images, verify removable media on the Texas flash host, and keep image templates fresh with sentinel-driven drift tracking.

Default flash host: {{.State.DefaultFlashHost}}

Replacement Run

This UI is meant for the one-shot recovery path: build the node image, verify the card on the flash host, then write it and hand off only the physical swap.

Live Jobs

Progress updates stream from the running Metis operation. The replacement flow automatically tries to clear the stale Kubernetes node object before the card write.

Sentinel Watch

Ariadne should hit the internal sentinel watch route on a schedule. You can also run it manually here when you want the latest template recommendations immediately.

Tracked nodes 0
Class targets 0

Recent Changes

This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.

`))