metis/pkg/service/server.go

629 lines
20 KiB
Go
Raw Normal View History

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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Metis Control</title>
<style>
:root{
--ink:#111318;
--muted:#616778;
--line:rgba(17,19,24,.12);
--paper:rgba(255,255,255,.84);
--paper-strong:#ffffff;
--brand:#1d5f8c;
--brand-deep:#153b59;
--accent:#d47b37;
--success:#1b8f5a;
--danger:#a63c35;
--shadow:0 20px 60px rgba(17,19,24,.12);
}
*{box-sizing:border-box}
body{
margin:0;
min-height:100vh;
font-family:"Avenir Next","Trebuchet MS","Segoe UI",sans-serif;
color:var(--ink);
background:
radial-gradient(circle at top left, rgba(212,123,55,.18), transparent 30rem),
radial-gradient(circle at top right, rgba(29,95,140,.18), transparent 32rem),
linear-gradient(180deg, #f8f4ee 0%, #eef2f5 48%, #e4edf2 100%);
}
.frame{
max-width:1280px;
margin:0 auto;
padding:2rem 1.25rem 3rem;
}
.mast{
display:flex;
justify-content:space-between;
align-items:flex-end;
gap:1.5rem;
margin-bottom:1.5rem;
}
.eyebrow{
letter-spacing:.14em;
text-transform:uppercase;
font-size:.72rem;
color:var(--brand-deep);
margin-bottom:.35rem;
font-weight:700;
}
h1{
margin:0;
font-size:clamp(2rem,4vw,3.4rem);
line-height:1;
}
.sub{
max-width:54rem;
color:var(--muted);
margin-top:.7rem;
font-size:1rem;
}
.badge{
display:inline-flex;
align-items:center;
gap:.45rem;
padding:.7rem .95rem;
background:rgba(255,255,255,.72);
border:1px solid rgba(21,59,89,.12);
border-radius:999px;
box-shadow:var(--shadow);
font-size:.9rem;
}
.grid{
display:grid;
grid-template-columns:1.2fr .9fr;
gap:1rem;
}
.stack{
display:grid;
gap:1rem;
}
.card{
background:var(--paper);
backdrop-filter:blur(14px);
border:1px solid var(--line);
border-radius:1.25rem;
padding:1.1rem;
box-shadow:var(--shadow);
}
.card h2{
margin:0 0 .35rem;
font-size:1rem;
text-transform:uppercase;
letter-spacing:.1em;
color:var(--brand-deep);
}
.hint{
color:var(--muted);
font-size:.92rem;
margin-bottom:1rem;
}
.form-grid{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:.85rem;
}
label{
display:grid;
gap:.35rem;
font-weight:600;
font-size:.92rem;
}
select, button{
width:100%;
border-radius:.85rem;
border:1px solid rgba(17,19,24,.14);
padding:.85rem .95rem;
font:inherit;
}
button{
cursor:pointer;
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
color:#fff;
border:none;
font-weight:700;
letter-spacing:.03em;
box-shadow:0 14px 30px rgba(21,59,89,.18);
}
button.secondary{
background:#fff;
color:var(--ink);
border:1px solid rgba(17,19,24,.14);
box-shadow:none;
}
.actions{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:.7rem;
margin-top:.9rem;
}
.list{
display:grid;
gap:.7rem;
max-height:30rem;
overflow:auto;
}
.item{
border:1px solid rgba(17,19,24,.1);
border-radius:1rem;
padding:.85rem .95rem;
background:rgba(255,255,255,.8);
}
.item-head{
display:flex;
justify-content:space-between;
gap:1rem;
margin-bottom:.35rem;
font-weight:700;
}
.meta{
color:var(--muted);
font-size:.85rem;
}
.bar{
height:.55rem;
background:rgba(17,19,24,.08);
border-radius:999px;
overflow:hidden;
margin-top:.7rem;
}
.bar > span{
display:block;
height:100%;
background:linear-gradient(90deg,var(--accent),var(--brand));
}
.pill{
display:inline-block;
padding:.2rem .55rem;
border-radius:999px;
font-size:.75rem;
text-transform:uppercase;
letter-spacing:.08em;
background:rgba(21,59,89,.08);
color:var(--brand-deep);
}
.pill.done{background:rgba(27,143,90,.12);color:var(--success)}
.pill.error{background:rgba(166,60,53,.12);color:var(--danger)}
.pill.running{background:rgba(212,123,55,.12);color:#9a5a20}
.mini{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:.7rem;
}
.stat{
padding:.8rem .9rem;
border-radius:1rem;
background:rgba(255,255,255,.72);
border:1px solid rgba(17,19,24,.08);
}
.stat strong{display:block;font-size:1.35rem}
code{
font-family:"IBM Plex Mono","SFMono-Regular","Menlo",monospace;
font-size:.88em;
}
@media (max-width: 980px){
.grid,.form-grid,.actions,.mini{grid-template-columns:1fr}
.mast{align-items:flex-start;flex-direction:column}
}
</style>
</head>
<body>
<main class="frame">
<section class="mast">
<div>
<div class="eyebrow">Atlas Recovery Plane</div>
<h1>Metis Control</h1>
<p class="sub">Build replacement node images, verify removable media on the Texas flash host, and keep image templates fresh with sentinel-driven drift tracking.</p>
</div>
<div class="badge"><strong>Default flash host:</strong> <span id="default-host">{{.State.DefaultFlashHost}}</span></div>
</section>
<section class="grid">
<div class="stack">
<article class="card">
<h2>Replacement Run</h2>
<p class="hint">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.</p>
<div class="form-grid">
<label>Target node
<select id="node-select"></select>
</label>
<label>Flash host
<select id="host-select"></select>
</label>
<label style="grid-column:1 / -1">Detected removable media
<select id="device-select"></select>
</label>
</div>
<div class="actions">
<button class="secondary" id="refresh-devices">Refresh media</button>
<button class="secondary" id="build-only">Build image only</button>
<button id="replace-run">Build and flash</button>
</div>
</article>
<article class="card">
<h2>Live Jobs</h2>
<p class="hint">Progress updates stream from the running Metis operation. The replacement flow automatically tries to clear the stale Kubernetes node object before the card write.</p>
<div id="jobs" class="list"></div>
</article>
</div>
<div class="stack">
<article class="card">
<h2>Sentinel Watch</h2>
<p class="hint">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.</p>
<div class="mini">
<div class="stat">
<span class="meta">Tracked nodes</span>
<strong id="snapshot-count">0</strong>
</div>
<div class="stat">
<span class="meta">Class targets</span>
<strong id="target-count">0</strong>
</div>
</div>
<div class="actions" style="grid-template-columns:1fr">
<button id="sentinel-watch">Run sentinel watch now</button>
</div>
</article>
<article class="card">
<h2>Recent Changes</h2>
<p class="hint">This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.</p>
<div id="events" class="list"></div>
</article>
</div>
</section>
</main>
<script id="boot" type="application/json">{{.BootJSON}}</script>
<script>
const boot = JSON.parse(document.getElementById('boot').textContent);
let state = boot;
const nodeSelect = document.getElementById('node-select');
const hostSelect = document.getElementById('host-select');
const deviceSelect = document.getElementById('device-select');
const jobsEl = document.getElementById('jobs');
const eventsEl = document.getElementById('events');
const snapshotCountEl = document.getElementById('snapshot-count');
const targetCountEl = document.getElementById('target-count');
function fmtTime(value){
if(!value){ return 'pending'; }
const date = new Date(value);
return isNaN(date.getTime()) ? value : date.toLocaleString();
}
function fmtBytes(value){
if(!value){ return '0 B'; }
const units = ['B','KiB','MiB','GiB','TiB'];
let size = Number(value);
let idx = 0;
while(size >= 1024 && idx < units.length - 1){
size /= 1024;
idx += 1;
}
return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
}
function setOptions(select, values, labeler){
const current = select.value;
select.innerHTML = '';
values.forEach((value)=>{
const option = document.createElement('option');
option.value = value;
option.textContent = labeler ? labeler(value) : value;
select.appendChild(option);
});
if(current && values.includes(current)){ select.value = current; }
}
function render(){
setOptions(nodeSelect, state.nodes.map((n)=>n.name));
setOptions(hostSelect, state.flash_hosts);
if(!hostSelect.value){ hostSelect.value = state.default_flash_host; }
setOptions(deviceSelect, state.devices.map((d)=>d.path), (path)=>{
const dev = state.devices.find((item)=>item.path === path);
if(!dev){ return path; }
return dev.path + ' · ' + fmtBytes(dev.size_bytes) + ' · ' + (dev.model || dev.transport || 'removable media');
});
jobsEl.innerHTML = '';
const jobs = state.jobs.length ? state.jobs : [{kind:'idle',status:'done',message:'No active or recent Metis jobs yet.',progress_pct:100,started_at:new Date().toISOString(),finished_at:new Date().toISOString()}];
jobs.forEach((job)=>{
const wrap = document.createElement('div');
wrap.className = 'item';
const statusClass = job.status === 'error' ? 'error' : (job.status === 'done' ? 'done' : (job.status === 'running' ? 'running' : ''));
const title = job.kind.toUpperCase() + (job.node ? ' · ' + job.node : '');
const started = fmtTime(job.started_at) + (job.device ? ' · ' + job.device : '') + (job.host ? ' · ' + job.host : '');
const progress = job.written_bytes ? (fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)) : '';
const detail = progress + (job.artifact ? ' · ' + job.artifact : '') + (job.error ? ' · ' + job.error : '');
wrap.innerHTML =
'<div class="item-head">' +
'<span>' + title + '</span>' +
'<span class="pill ' + statusClass + '">' + job.status + '</span>' +
'</div>' +
'<div>' + (job.message || job.stage || 'queued') + '</div>' +
'<div class="meta">' + started + '</div>' +
'<div class="meta">' + detail + '</div>' +
'<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
jobsEl.appendChild(wrap);
});
eventsEl.innerHTML = '';
state.events.forEach((event)=>{
const wrap = document.createElement('div');
wrap.className = 'item';
wrap.innerHTML =
'<div class="item-head">' +
'<span>' + event.summary + '</span>' +
'<span class="meta">' + fmtTime(event.time) + '</span>' +
'</div>' +
'<div class="meta"><code>' + event.kind + '</code></div>';
eventsEl.appendChild(wrap);
});
snapshotCountEl.textContent = state.snapshots.length;
targetCountEl.textContent = Object.keys(state.targets || {}).length;
}
async function refreshState(){
const host = hostSelect.value || state.default_flash_host;
const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
if(resp.ok){
state = await resp.json();
render();
}
}
async function post(path, body){
const resp = await fetch(path, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if(!resp.ok){
const text = await resp.text();
throw new Error(text || ('Request failed for ' + path));
}
return resp.json();
}
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
await refreshState();
});
document.getElementById('build-only').addEventListener('click', async ()=>{
await post('/api/jobs/build', {node: nodeSelect.value});
await refreshState();
});
document.getElementById('replace-run').addEventListener('click', async ()=>{
await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
await refreshState();
});
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
await post('/api/sentinel/watch', {});
await refreshState();
});
hostSelect.addEventListener('change', refreshState);
render();
setInterval(refreshState, 5000);
</script>
</body>
</html>`))