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.
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.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(), 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 } 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(), 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 := 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) } var metisPage = template.Must(template.New("metis").Parse(`
Build replacement node images, verify removable media on the Texas flash host, and keep image templates fresh with sentinel-driven drift tracking.
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.
Progress updates stream from the running Metis operation. The replacement flow automatically tries to clear the stale Kubernetes node object before the card write.
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.
This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.