service: tighten metis access and recovery ui
This commit is contained in:
parent
a103a654f7
commit
791d528a99
@ -9,50 +9,50 @@ import (
|
||||
|
||||
// Inventory is the root document defining node classes and per-node specs.
|
||||
type Inventory struct {
|
||||
Classes []NodeClass `yaml:"classes"`
|
||||
Nodes []NodeSpec `yaml:"nodes"`
|
||||
Classes []NodeClass `yaml:"classes" json:"classes"`
|
||||
Nodes []NodeSpec `yaml:"nodes" json:"nodes"`
|
||||
}
|
||||
|
||||
// NodeClass defines a reusable image/config for a group of nodes.
|
||||
type NodeClass struct {
|
||||
Name string `yaml:"name"`
|
||||
Arch string `yaml:"arch"`
|
||||
OS string `yaml:"os"`
|
||||
Image string `yaml:"image"`
|
||||
Checksum string `yaml:"checksum,omitempty"`
|
||||
K3sVersion string `yaml:"k3s_version,omitempty"`
|
||||
BootloaderNote string `yaml:"bootloader_note,omitempty"`
|
||||
DefaultLabels map[string]string `yaml:"default_labels,omitempty"`
|
||||
DefaultTaints []string `yaml:"default_taints,omitempty"`
|
||||
CloudInit string `yaml:"cloud_init,omitempty"`
|
||||
BootOverlay string `yaml:"boot_overlay,omitempty"` // path to overlay files for boot partition
|
||||
RootOverlay string `yaml:"root_overlay,omitempty"` // path to overlay files for rootfs
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Arch string `yaml:"arch" json:"arch"`
|
||||
OS string `yaml:"os" json:"os"`
|
||||
Image string `yaml:"image" json:"image"`
|
||||
Checksum string `yaml:"checksum,omitempty" json:"checksum,omitempty"`
|
||||
K3sVersion string `yaml:"k3s_version,omitempty" json:"k3s_version,omitempty"`
|
||||
BootloaderNote string `yaml:"bootloader_note,omitempty" json:"bootloader_note,omitempty"`
|
||||
DefaultLabels map[string]string `yaml:"default_labels,omitempty" json:"default_labels,omitempty"`
|
||||
DefaultTaints []string `yaml:"default_taints,omitempty" json:"default_taints,omitempty"`
|
||||
CloudInit string `yaml:"cloud_init,omitempty" json:"cloud_init,omitempty"`
|
||||
BootOverlay string `yaml:"boot_overlay,omitempty" json:"boot_overlay,omitempty"` // path to overlay files for boot partition
|
||||
RootOverlay string `yaml:"root_overlay,omitempty" json:"root_overlay,omitempty"` // path to overlay files for rootfs
|
||||
}
|
||||
|
||||
// NodeSpec captures per-node overrides and identity.
|
||||
type NodeSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
Class string `yaml:"class"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
IP string `yaml:"ip"`
|
||||
MAC string `yaml:"mac,omitempty"`
|
||||
K3sRole string `yaml:"k3s_role"`
|
||||
K3sVersion string `yaml:"k3s_version,omitempty"`
|
||||
K3sToken string `yaml:"k3s_token,omitempty"`
|
||||
K3sURL string `yaml:"k3s_url,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Taints []string `yaml:"taints,omitempty"`
|
||||
LonghornDisks []LonghornDisk `yaml:"longhorn_disks,omitempty"`
|
||||
SSHUser string `yaml:"ssh_user,omitempty"`
|
||||
SSHAuthorized []string `yaml:"ssh_authorized_keys,omitempty"`
|
||||
Notes string `yaml:"notes,omitempty"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Class string `yaml:"class" json:"class"`
|
||||
Hostname string `yaml:"hostname" json:"hostname"`
|
||||
IP string `yaml:"ip" json:"ip"`
|
||||
MAC string `yaml:"mac,omitempty" json:"mac,omitempty"`
|
||||
K3sRole string `yaml:"k3s_role" json:"k3s_role"`
|
||||
K3sVersion string `yaml:"k3s_version,omitempty" json:"k3s_version,omitempty"`
|
||||
K3sToken string `yaml:"k3s_token,omitempty" json:"k3s_token,omitempty"`
|
||||
K3sURL string `yaml:"k3s_url,omitempty" json:"k3s_url,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
|
||||
Taints []string `yaml:"taints,omitempty" json:"taints,omitempty"`
|
||||
LonghornDisks []LonghornDisk `yaml:"longhorn_disks,omitempty" json:"longhorn_disks,omitempty"`
|
||||
SSHUser string `yaml:"ssh_user,omitempty" json:"ssh_user,omitempty"`
|
||||
SSHAuthorized []string `yaml:"ssh_authorized_keys,omitempty" json:"ssh_authorized_keys,omitempty"`
|
||||
Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// LonghornDisk describes an attached disk to mount for Longhorn.
|
||||
type LonghornDisk struct {
|
||||
Mountpoint string `yaml:"mountpoint"`
|
||||
UUID string `yaml:"uuid"`
|
||||
FS string `yaml:"fs,omitempty"`
|
||||
Mountpoint string `yaml:"mountpoint" json:"mountpoint"`
|
||||
UUID string `yaml:"uuid" json:"uuid"`
|
||||
FS string `yaml:"fs,omitempty" json:"fs,omitempty"`
|
||||
}
|
||||
|
||||
// Load reads and parses an inventory file.
|
||||
|
||||
@ -86,10 +86,13 @@ type SnapshotRecord struct {
|
||||
type PageState struct {
|
||||
LocalHost string `json:"local_host"`
|
||||
DefaultFlashHost string `json:"default_flash_host"`
|
||||
SelectedHost string `json:"selected_host"`
|
||||
FlashHosts []string `json:"flash_hosts"`
|
||||
Nodes []inventory.NodeSpec `json:"nodes"`
|
||||
Jobs []*Job `json:"jobs"`
|
||||
Devices []Device `json:"devices"`
|
||||
PreferredDevice string `json:"preferred_device,omitempty"`
|
||||
DeviceError string `json:"device_error,omitempty"`
|
||||
Events []Event `json:"events"`
|
||||
Snapshots []SnapshotRecord `json:"snapshots"`
|
||||
Targets map[string]facts.Targets `json:"targets"`
|
||||
@ -145,6 +148,9 @@ func NewApp(settings Settings) (*App, error) {
|
||||
|
||||
// State returns the current UI/API snapshot.
|
||||
func (a *App) State(deviceHost string) PageState {
|
||||
if strings.TrimSpace(deviceHost) == "" {
|
||||
deviceHost = a.settings.DefaultFlashHost
|
||||
}
|
||||
a.mu.RLock()
|
||||
jobs := make([]*Job, 0, len(a.jobs))
|
||||
for _, job := range a.jobs {
|
||||
@ -169,14 +175,19 @@ func (a *App) State(deviceHost string) PageState {
|
||||
return snaps[i].Node < snaps[j].Node
|
||||
})
|
||||
|
||||
devices, _ := a.ListDevices(deviceHost)
|
||||
flashHosts := a.flashHosts()
|
||||
devices, deviceErr := a.ListDevices(deviceHost)
|
||||
preferredDevice := preferredDevice(devices)
|
||||
return PageState{
|
||||
LocalHost: a.settings.LocalHost,
|
||||
DefaultFlashHost: a.settings.DefaultFlashHost,
|
||||
FlashHosts: append([]string{}, a.settings.FlashHosts...),
|
||||
SelectedHost: deviceHost,
|
||||
FlashHosts: flashHosts,
|
||||
Nodes: append([]inventory.NodeSpec{}, a.inventory.Nodes...),
|
||||
Jobs: jobs,
|
||||
Devices: devices,
|
||||
PreferredDevice: preferredDevice,
|
||||
DeviceError: errorString(deviceErr),
|
||||
Events: a.recentEvents(40),
|
||||
Snapshots: snaps,
|
||||
Targets: aTargets,
|
||||
@ -199,13 +210,10 @@ func (a *App) Replace(node, host, device string) (*Job, error) {
|
||||
if host == "" {
|
||||
host = a.settings.DefaultFlashHost
|
||||
}
|
||||
if host != a.settings.LocalHost && host != a.settings.DefaultFlashHost {
|
||||
return nil, fmt.Errorf("flash host %s is not available on this Metis instance", host)
|
||||
}
|
||||
if _, _, err := a.inventory.FindNode(node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := a.ensureDevice(device); err != nil {
|
||||
if _, err := a.ensureDevice(host, device); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job := a.newJob("replace", node, host, device)
|
||||
@ -299,8 +307,8 @@ func (a *App) ListDevices(host string) ([]Device, error) {
|
||||
if host == "" {
|
||||
host = a.settings.DefaultFlashHost
|
||||
}
|
||||
if host != a.settings.LocalHost && host != a.settings.DefaultFlashHost {
|
||||
return nil, fmt.Errorf("flash host %s is not attached to this Metis instance", host)
|
||||
if !a.supportsLocalMedia(host) {
|
||||
return nil, fmt.Errorf("flash host %s is listed for planning, but this Metis instance only has direct removable-media access on %s", host, a.settings.LocalHost)
|
||||
}
|
||||
cmd := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,RM,HOTPLUG,SIZE,MODEL,TRAN,TYPE")
|
||||
out, err := cmd.Output()
|
||||
@ -351,7 +359,17 @@ func (a *App) ListDevices(host string) ([]Device, error) {
|
||||
SizeBytes: size,
|
||||
})
|
||||
}
|
||||
sort.Slice(devices, func(i, j int) bool { return devices[i].Path < devices[j].Path })
|
||||
sort.Slice(devices, func(i, j int) bool {
|
||||
left := deviceScore(devices[i])
|
||||
right := deviceScore(devices[j])
|
||||
if left != right {
|
||||
return left > right
|
||||
}
|
||||
if devices[i].SizeBytes != devices[j].SizeBytes {
|
||||
return devices[i].SizeBytes < devices[j].SizeBytes
|
||||
}
|
||||
return devices[i].Path < devices[j].Path
|
||||
})
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
@ -443,7 +461,7 @@ func (a *App) runBuild(job *Job, flash bool) {
|
||||
j.ProgressPct = 78
|
||||
j.Artifact = output
|
||||
})
|
||||
if _, err := a.ensureDevice(job.Device); err != nil {
|
||||
if _, err := a.ensureDevice(job.Host, job.Device); err != nil {
|
||||
a.failJob(job.ID, err)
|
||||
a.metrics.RecordFlash(job.Node, job.Host, "error")
|
||||
return
|
||||
@ -502,8 +520,11 @@ func (a *App) flashArtifact(jobID, artifact string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) ensureDevice(path string) (*Device, error) {
|
||||
devices, err := a.ListDevices(a.settings.DefaultFlashHost)
|
||||
func (a *App) ensureDevice(host, path string) (*Device, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, fmt.Errorf("select removable media before starting a flash run")
|
||||
}
|
||||
devices, err := a.ListDevices(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -628,6 +649,32 @@ func (a *App) artifactPath(node string) string {
|
||||
return filepath.Join(a.settings.ArtifactDir, fmt.Sprintf("%s.img", node))
|
||||
}
|
||||
|
||||
func (a *App) flashHosts() []string {
|
||||
hosts := map[string]struct{}{}
|
||||
for _, host := range a.settings.FlashHosts {
|
||||
if value := strings.TrimSpace(host); value != "" {
|
||||
hosts[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, host := range []string{a.settings.DefaultFlashHost, a.settings.LocalHost} {
|
||||
if value := strings.TrimSpace(host); value != "" {
|
||||
hosts[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, host := range clusterNodeNames() {
|
||||
hosts[host] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(hosts))
|
||||
for host := range hosts {
|
||||
out = append(out, host)
|
||||
}
|
||||
sort.Strings(out)
|
||||
if a.settings.DefaultFlashHost == "" {
|
||||
return out
|
||||
}
|
||||
return moveToFront(out, a.settings.DefaultFlashHost)
|
||||
}
|
||||
|
||||
func (a *App) loadSnapshots() error {
|
||||
data, err := os.ReadFile(a.settings.SnapshotsPath)
|
||||
if err != nil {
|
||||
@ -742,6 +789,67 @@ func firstLine(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func preferredDevice(devices []Device) string {
|
||||
if len(devices) == 0 {
|
||||
return ""
|
||||
}
|
||||
return devices[0].Path
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (a *App) supportsLocalMedia(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
return host == "" || host == a.settings.LocalHost || host == a.settings.DefaultFlashHost
|
||||
}
|
||||
|
||||
func deviceScore(device Device) int {
|
||||
score := 0
|
||||
model := strings.ToLower(strings.TrimSpace(device.Model))
|
||||
switch {
|
||||
case strings.Contains(model, "microsd"), strings.Contains(model, "micro sd"):
|
||||
score += 60
|
||||
case strings.Contains(model, "sdxc"), strings.Contains(model, "sdhc"), strings.Contains(model, "sd "):
|
||||
score += 50
|
||||
case strings.Contains(model, "card"), strings.Contains(model, "reader"):
|
||||
score += 40
|
||||
}
|
||||
if device.Removable {
|
||||
score += 20
|
||||
}
|
||||
if device.Hotplug {
|
||||
score += 10
|
||||
}
|
||||
if device.Transport == "usb" {
|
||||
score += 5
|
||||
}
|
||||
if strings.HasPrefix(device.Name, "mmcblk") {
|
||||
score += 25
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func moveToFront(values []string, preferred string) []string {
|
||||
if preferred == "" || len(values) < 2 {
|
||||
return values
|
||||
}
|
||||
out := append([]string{}, values...)
|
||||
for idx, value := range out {
|
||||
if value != preferred {
|
||||
continue
|
||||
}
|
||||
copy(out[1:idx+1], out[:idx])
|
||||
out[0] = preferred
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deleteNodeObject(node string) error {
|
||||
if err := deleteNodeObjectInCluster(node); err == nil {
|
||||
return nil
|
||||
@ -793,3 +901,60 @@ func deleteNodeObjectInCluster(node string) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("delete node %s failed: %s: %s", node, resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
func clusterNodeNames() []string {
|
||||
host := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST"))
|
||||
port := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT"))
|
||||
if host == "" || port == "" {
|
||||
return nil
|
||||
}
|
||||
token, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
caPEM, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool},
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s:%s/api/v1/nodes", host, port), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
var payload struct {
|
||||
Items []struct {
|
||||
Metadata struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"metadata"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(payload.Items))
|
||||
for _, item := range payload.Items {
|
||||
if name := strings.TrimSpace(item.Metadata.Name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
@ -161,15 +161,9 @@ func (a *App) withUIAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
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")
|
||||
if user == "" {
|
||||
return userContext{}, false
|
||||
}
|
||||
groups := splitHeaderList(firstNonEmptyHeader(r, "X-Auth-Request-Groups", "X-Forwarded-Groups"))
|
||||
normalizedUser := normalizeUserValue(user)
|
||||
for _, allowedUser := range a.settings.AllowedUsers {
|
||||
if normalizeUserValue(allowedUser) == normalizedUser {
|
||||
return userContext{Name: user, Groups: groups}, true
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return userContext{Name: user, Groups: groups}, false
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, allowed := range a.settings.AllowedGroups {
|
||||
@ -205,10 +199,6 @@ func splitHeaderList(raw string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeUserValue(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func normalizeGroupValue(raw string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
return strings.TrimPrefix(value, "/")
|
||||
@ -243,17 +233,21 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
<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);
|
||||
--bg:#081018;
|
||||
--bg-soft:#0e1722;
|
||||
--panel:#101c29;
|
||||
--panel-strong:#172535;
|
||||
--line:rgba(149,177,205,.18);
|
||||
--line-strong:rgba(149,177,205,.28);
|
||||
--ink:#f3f7fb;
|
||||
--muted:#9bb0c4;
|
||||
--brand:#3da7ff;
|
||||
--brand-deep:#1c6ca8;
|
||||
--accent:#ff9a4a;
|
||||
--success:#3dd08c;
|
||||
--danger:#ff6f6f;
|
||||
--warn:#f2c14c;
|
||||
--shadow:0 24px 60px rgba(0,0,0,.35);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
@ -262,12 +256,12 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
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%);
|
||||
radial-gradient(circle at top left, rgba(61,167,255,.20), transparent 28rem),
|
||||
radial-gradient(circle at top right, rgba(255,154,74,.16), transparent 26rem),
|
||||
linear-gradient(180deg, #071018 0%, #0a131d 50%, #0b1622 100%);
|
||||
}
|
||||
.frame{
|
||||
max-width:1280px;
|
||||
max-width:1320px;
|
||||
margin:0 auto;
|
||||
padding:2rem 1.25rem 3rem;
|
||||
}
|
||||
@ -276,15 +270,15 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
justify-content:space-between;
|
||||
align-items:flex-end;
|
||||
gap:1.5rem;
|
||||
margin-bottom:1.5rem;
|
||||
margin-bottom:1rem;
|
||||
}
|
||||
.eyebrow{
|
||||
letter-spacing:.14em;
|
||||
text-transform:uppercase;
|
||||
font-size:.72rem;
|
||||
color:var(--brand-deep);
|
||||
color:#81c6ff;
|
||||
margin-bottom:.35rem;
|
||||
font-weight:700;
|
||||
font-weight:800;
|
||||
}
|
||||
h1{
|
||||
margin:0;
|
||||
@ -292,7 +286,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
line-height:1;
|
||||
}
|
||||
.sub{
|
||||
max-width:54rem;
|
||||
max-width:56rem;
|
||||
color:var(--muted);
|
||||
margin-top:.7rem;
|
||||
font-size:1rem;
|
||||
@ -301,27 +295,40 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
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);
|
||||
padding:.78rem 1rem;
|
||||
background:rgba(16,28,41,.82);
|
||||
border:1px solid var(--line);
|
||||
border-radius:999px;
|
||||
box-shadow:var(--shadow);
|
||||
font-size:.9rem;
|
||||
font-size:.92rem;
|
||||
}
|
||||
.banner{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
gap:.75rem;
|
||||
padding:1rem 1.1rem;
|
||||
border-radius:1rem;
|
||||
border:1px solid var(--line);
|
||||
background:rgba(16,28,41,.84);
|
||||
margin-bottom:1rem;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.banner.hidden{display:none}
|
||||
.banner.info{border-color:rgba(61,167,255,.32);background:rgba(17,36,54,.88)}
|
||||
.banner.success{border-color:rgba(61,208,140,.32);background:rgba(10,41,31,.9)}
|
||||
.banner.error{border-color:rgba(255,111,111,.32);background:rgba(56,18,18,.9)}
|
||||
.banner.warn{border-color:rgba(242,193,76,.32);background:rgba(53,40,13,.9)}
|
||||
.banner strong{display:block;margin-bottom:.15rem}
|
||||
.grid{
|
||||
display:grid;
|
||||
grid-template-columns:1.2fr .9fr;
|
||||
gap:1rem;
|
||||
}
|
||||
.stack{
|
||||
display:grid;
|
||||
grid-template-columns:1.16fr .9fr;
|
||||
gap:1rem;
|
||||
}
|
||||
.stack{display:grid;gap:1rem}
|
||||
.card{
|
||||
background:var(--paper);
|
||||
backdrop-filter:blur(14px);
|
||||
background:linear-gradient(180deg, rgba(16,28,41,.95), rgba(12,21,31,.94));
|
||||
border:1px solid var(--line);
|
||||
border-radius:1.25rem;
|
||||
border-radius:1.35rem;
|
||||
padding:1.1rem;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
@ -330,13 +337,19 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
font-size:1rem;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.1em;
|
||||
color:var(--brand-deep);
|
||||
color:#8bccff;
|
||||
}
|
||||
.hint{
|
||||
color:var(--muted);
|
||||
font-size:.92rem;
|
||||
margin-bottom:1rem;
|
||||
}
|
||||
.microcopy{
|
||||
color:var(--muted);
|
||||
font-size:.84rem;
|
||||
margin-top:.5rem;
|
||||
min-height:1.2rem;
|
||||
}
|
||||
.form-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
@ -345,31 +358,47 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
label{
|
||||
display:grid;
|
||||
gap:.35rem;
|
||||
font-weight:600;
|
||||
font-weight:700;
|
||||
font-size:.92rem;
|
||||
}
|
||||
select, button{
|
||||
width:100%;
|
||||
border-radius:.85rem;
|
||||
border:1px solid rgba(17,19,24,.14);
|
||||
padding:.85rem .95rem;
|
||||
border-radius:.95rem;
|
||||
border:1px solid var(--line-strong);
|
||||
padding:.9rem .95rem;
|
||||
font:inherit;
|
||||
}
|
||||
select{
|
||||
background:rgba(11,21,33,.96);
|
||||
color:var(--ink);
|
||||
min-height:3.1rem;
|
||||
}
|
||||
select:focus, button:focus{
|
||||
outline:2px solid rgba(61,167,255,.28);
|
||||
outline-offset:2px;
|
||||
}
|
||||
button{
|
||||
cursor:pointer;
|
||||
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
|
||||
color:#fff;
|
||||
border:none;
|
||||
font-weight:700;
|
||||
font-weight:800;
|
||||
letter-spacing:.03em;
|
||||
box-shadow:0 14px 30px rgba(21,59,89,.18);
|
||||
box-shadow:0 14px 30px rgba(17,66,102,.30);
|
||||
transition:transform .12s ease, opacity .12s ease;
|
||||
}
|
||||
button:hover{transform:translateY(-1px)}
|
||||
button.secondary{
|
||||
background:#fff;
|
||||
background:rgba(18,30,43,.96);
|
||||
color:var(--ink);
|
||||
border:1px solid rgba(17,19,24,.14);
|
||||
border:1px solid var(--line-strong);
|
||||
box-shadow:none;
|
||||
}
|
||||
button:disabled{
|
||||
opacity:.55;
|
||||
cursor:not-allowed;
|
||||
transform:none;
|
||||
}
|
||||
.actions{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
@ -383,10 +412,10 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
overflow:auto;
|
||||
}
|
||||
.item{
|
||||
border:1px solid rgba(17,19,24,.1);
|
||||
border:1px solid rgba(149,177,205,.14);
|
||||
border-radius:1rem;
|
||||
padding:.85rem .95rem;
|
||||
background:rgba(255,255,255,.8);
|
||||
background:rgba(8,17,27,.78);
|
||||
}
|
||||
.item-head{
|
||||
display:flex;
|
||||
@ -401,7 +430,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
}
|
||||
.bar{
|
||||
height:.55rem;
|
||||
background:rgba(17,19,24,.08);
|
||||
background:rgba(149,177,205,.12);
|
||||
border-radius:999px;
|
||||
overflow:hidden;
|
||||
margin-top:.7rem;
|
||||
@ -418,12 +447,12 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
font-size:.75rem;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.08em;
|
||||
background:rgba(21,59,89,.08);
|
||||
color:var(--brand-deep);
|
||||
background:rgba(61,167,255,.12);
|
||||
color:#9bd1ff;
|
||||
}
|
||||
.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}
|
||||
.pill.done{background:rgba(61,208,140,.12);color:var(--success)}
|
||||
.pill.error{background:rgba(255,111,111,.12);color:var(--danger)}
|
||||
.pill.running{background:rgba(255,154,74,.14);color:var(--accent)}
|
||||
.mini{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
@ -432,10 +461,16 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
.stat{
|
||||
padding:.8rem .9rem;
|
||||
border-radius:1rem;
|
||||
background:rgba(255,255,255,.72);
|
||||
border:1px solid rgba(17,19,24,.08);
|
||||
background:rgba(8,17,27,.72);
|
||||
border:1px solid rgba(149,177,205,.12);
|
||||
}
|
||||
.stat strong{display:block;font-size:1.35rem}
|
||||
.row{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
gap:1rem;
|
||||
align-items:center;
|
||||
}
|
||||
code{
|
||||
font-family:"IBM Plex Mono","SFMono-Regular","Menlo",monospace;
|
||||
font-size:.88em;
|
||||
@ -443,6 +478,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
@media (max-width: 980px){
|
||||
.grid,.form-grid,.actions,.mini{grid-template-columns:1fr}
|
||||
.mast{align-items:flex-start;flex-direction:column}
|
||||
.row{align-items:flex-start;flex-direction:column}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@ -457,6 +493,13 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
<div class="badge"><strong>Default flash host:</strong> <span id="default-host">{{.State.DefaultFlashHost}}</span></div>
|
||||
</section>
|
||||
|
||||
<section id="status-banner" class="banner hidden" aria-live="polite">
|
||||
<div>
|
||||
<strong id="status-title">Ready</strong>
|
||||
<div id="status-text">Metis is ready.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="stack">
|
||||
<article class="card">
|
||||
@ -473,6 +516,8 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
<select id="device-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="microcopy" id="host-note"></div>
|
||||
<div class="microcopy" id="device-note"></div>
|
||||
<div class="actions">
|
||||
<button class="secondary" id="refresh-devices">Refresh media</button>
|
||||
<button class="secondary" id="build-only">Build image only</button>
|
||||
@ -507,8 +552,12 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="row">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="events" class="list"></div>
|
||||
</article>
|
||||
</div>
|
||||
@ -518,6 +567,8 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
<script>
|
||||
const boot = JSON.parse(document.getElementById('boot').textContent);
|
||||
let state = boot;
|
||||
let busy = false;
|
||||
|
||||
const nodeSelect = document.getElementById('node-select');
|
||||
const hostSelect = document.getElementById('host-select');
|
||||
const deviceSelect = document.getElementById('device-select');
|
||||
@ -525,12 +576,19 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
const eventsEl = document.getElementById('events');
|
||||
const snapshotCountEl = document.getElementById('snapshot-count');
|
||||
const targetCountEl = document.getElementById('target-count');
|
||||
const hostNoteEl = document.getElementById('host-note');
|
||||
const deviceNoteEl = document.getElementById('device-note');
|
||||
const bannerEl = document.getElementById('status-banner');
|
||||
const bannerTitleEl = document.getElementById('status-title');
|
||||
const bannerTextEl = document.getElementById('status-text');
|
||||
const actionButtons = Array.from(document.querySelectorAll('button'));
|
||||
|
||||
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'];
|
||||
@ -542,27 +600,51 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
}
|
||||
return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
|
||||
}
|
||||
function setOptions(select, values, labeler){
|
||||
|
||||
function banner(kind, title, text){
|
||||
bannerEl.className = 'banner ' + kind;
|
||||
bannerTitleEl.textContent = title;
|
||||
bannerTextEl.textContent = text;
|
||||
}
|
||||
|
||||
function clearBanner(){
|
||||
bannerEl.className = 'banner hidden';
|
||||
}
|
||||
|
||||
function setBusy(nextBusy){
|
||||
busy = nextBusy;
|
||||
actionButtons.forEach((button)=>{
|
||||
button.disabled = nextBusy;
|
||||
});
|
||||
}
|
||||
|
||||
function bestDevicePath(){
|
||||
return state.preferred_device || (state.devices[0] ? state.devices[0].path : '');
|
||||
}
|
||||
|
||||
function setOptions(select, values, labeler, emptyLabel){
|
||||
const current = select.value;
|
||||
select.innerHTML = '';
|
||||
if(!values.length){
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = emptyLabel;
|
||||
select.appendChild(option);
|
||||
select.value = '';
|
||||
return;
|
||||
}
|
||||
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; }
|
||||
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');
|
||||
});
|
||||
|
||||
function renderJobs(){
|
||||
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)=>{
|
||||
@ -571,8 +653,10 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
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 : '');
|
||||
const detailBits = [];
|
||||
if(job.written_bytes){ detailBits.push(fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)); }
|
||||
if(job.artifact){ detailBits.push(job.artifact); }
|
||||
if(job.error){ detailBits.push(job.error); }
|
||||
wrap.innerHTML =
|
||||
'<div class="item-head">' +
|
||||
'<span>' + title + '</span>' +
|
||||
@ -580,11 +664,13 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
'</div>' +
|
||||
'<div>' + (job.message || job.stage || 'queued') + '</div>' +
|
||||
'<div class="meta">' + started + '</div>' +
|
||||
'<div class="meta">' + detail + '</div>' +
|
||||
'<div class="meta">' + detailBits.join(' · ') + '</div>' +
|
||||
'<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
|
||||
jobsEl.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEvents(){
|
||||
eventsEl.innerHTML = '';
|
||||
state.events.forEach((event)=>{
|
||||
const wrap = document.createElement('div');
|
||||
@ -597,17 +683,78 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
'<div class="meta"><code>' + event.kind + '</code></div>';
|
||||
eventsEl.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function render(){
|
||||
const nodeNames = (state.nodes || []).map((node)=>node.name).filter(Boolean);
|
||||
setOptions(nodeSelect, nodeNames, null, 'No replacement nodes available');
|
||||
if(!nodeSelect.value && nodeNames.length){
|
||||
nodeSelect.value = nodeNames[0];
|
||||
}
|
||||
|
||||
setOptions(hostSelect, state.flash_hosts || [], null, 'No flash hosts available');
|
||||
if(state.selected_host && (state.flash_hosts || []).includes(state.selected_host)){
|
||||
hostSelect.value = state.selected_host;
|
||||
}
|
||||
if(!hostSelect.value && state.default_flash_host){
|
||||
hostSelect.value = state.default_flash_host;
|
||||
}
|
||||
|
||||
const devicePaths = (state.devices || []).map((device)=>device.path);
|
||||
setOptions(deviceSelect, devicePaths, (path)=>{
|
||||
const dev = state.devices.find((item)=>item.path === path);
|
||||
if(!dev){ return path; }
|
||||
const parts = [dev.path, fmtBytes(dev.size_bytes)];
|
||||
if(dev.model){ parts.push(dev.model); }
|
||||
else if(dev.transport){ parts.push(dev.transport); }
|
||||
return parts.join(' · ');
|
||||
}, state.device_error || 'No removable media detected');
|
||||
|
||||
const preferredDevice = bestDevicePath();
|
||||
const selectedDeviceStillExists = devicePaths.includes(deviceSelect.value);
|
||||
if(preferredDevice && (!selectedDeviceStillExists || !deviceSelect.value)){
|
||||
deviceSelect.value = preferredDevice;
|
||||
}
|
||||
|
||||
const selectedHost = hostSelect.value || state.default_flash_host;
|
||||
const hostIsLocal = selectedHost === state.local_host || selectedHost === state.default_flash_host;
|
||||
hostNoteEl.textContent = hostIsLocal
|
||||
? 'Metis is running on ' + state.local_host + ', so media detection and flashing are live for this host.'
|
||||
: 'The selected host is listed from cluster inventory, but this Metis instance only has direct media access on ' + state.local_host + '.';
|
||||
|
||||
if(state.device_error){
|
||||
deviceNoteEl.textContent = state.device_error;
|
||||
} else if(state.devices.length){
|
||||
deviceNoteEl.textContent = 'Best candidate preselected: ' + (bestDevicePath() || 'none');
|
||||
} else {
|
||||
deviceNoteEl.textContent = 'Insert an SD card or removable drive on the selected flash host, then refresh media.';
|
||||
}
|
||||
|
||||
document.getElementById('build-only').disabled = busy || !nodeSelect.value;
|
||||
document.getElementById('refresh-devices').disabled = busy;
|
||||
document.getElementById('replace-run').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error;
|
||||
document.getElementById('sentinel-watch').disabled = busy;
|
||||
|
||||
renderJobs();
|
||||
renderEvents();
|
||||
snapshotCountEl.textContent = state.snapshots.length;
|
||||
targetCountEl.textContent = Object.keys(state.targets || {}).length;
|
||||
}
|
||||
async function refreshState(){
|
||||
|
||||
async function refreshState(opts = {}){
|
||||
const host = hostSelect.value || state.default_flash_host;
|
||||
const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
|
||||
if(resp.ok){
|
||||
if(!resp.ok){
|
||||
const text = await resp.text();
|
||||
throw new Error(text || 'Could not refresh Metis state');
|
||||
}
|
||||
state = await resp.json();
|
||||
render();
|
||||
if(!opts.silent && state.device_error){
|
||||
banner('warn', 'Flash host needs attention', state.device_error);
|
||||
}
|
||||
}
|
||||
|
||||
async function post(path, body){
|
||||
const resp = await fetch(path, {
|
||||
method:'POST',
|
||||
@ -618,27 +765,101 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
||||
const text = await resp.text();
|
||||
throw new Error(text || ('Request failed for ' + path));
|
||||
}
|
||||
return resp.json();
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
return contentType.includes('application/json') ? resp.json() : {};
|
||||
}
|
||||
|
||||
function requireValue(value, message){
|
||||
if(stringsafe(value)){
|
||||
return true;
|
||||
}
|
||||
banner('error', 'Missing input', message);
|
||||
return false;
|
||||
}
|
||||
|
||||
function stringsafe(value){
|
||||
return !!String(value || '').trim();
|
||||
}
|
||||
|
||||
async function runAction(title, pending, fn){
|
||||
try {
|
||||
setBusy(true);
|
||||
banner('info', title, pending);
|
||||
await fn();
|
||||
} catch (error) {
|
||||
banner('error', title + ' failed', error.message || String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
|
||||
await runAction('Refreshing media', 'Checking removable devices on the selected flash host.', async ()=>{
|
||||
await refreshState();
|
||||
if(state.device_error){
|
||||
banner('warn', 'Flash host needs attention', state.device_error);
|
||||
return;
|
||||
}
|
||||
banner('success', 'Media refreshed', state.devices.length ? 'Detected ' + state.devices.length + ' flash candidate(s).' : 'No removable media candidates are visible yet.');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('build-only').addEventListener('click', async ()=>{
|
||||
if(!requireValue(nodeSelect.value, 'Choose the target node image you want Metis to build first.')){
|
||||
return;
|
||||
}
|
||||
await runAction('Starting image build', 'Queueing the node image build now.', async ()=>{
|
||||
await post('/api/jobs/build', {node: nodeSelect.value});
|
||||
await refreshState();
|
||||
await refreshState({silent:true});
|
||||
banner('success', 'Image build queued', 'Metis started building the replacement image for ' + nodeSelect.value + '.');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('replace-run').addEventListener('click', async ()=>{
|
||||
if(!requireValue(nodeSelect.value, 'Choose the target node whose SD card image should be built and flashed.')){
|
||||
return;
|
||||
}
|
||||
if(!requireValue(deviceSelect.value, 'Choose removable media before starting a build-and-flash run.')){
|
||||
return;
|
||||
}
|
||||
if(state.device_error){
|
||||
banner('error', 'Flash host unavailable', state.device_error);
|
||||
return;
|
||||
}
|
||||
await runAction('Starting build and flash', 'Queueing the full replacement workflow now.', async ()=>{
|
||||
await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
|
||||
await refreshState();
|
||||
await refreshState({silent:true});
|
||||
banner('success', 'Replacement workflow queued', 'Metis is building the image for ' + nodeSelect.value + ' and will flash ' + deviceSelect.value + '.');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
|
||||
await runAction('Running sentinel watch', 'Refreshing template recommendations from the latest snapshots.', async ()=>{
|
||||
await post('/api/sentinel/watch', {});
|
||||
await refreshState();
|
||||
await refreshState({silent:true});
|
||||
banner('success', 'Sentinel watch complete', 'Metis refreshed its template recommendations.');
|
||||
});
|
||||
hostSelect.addEventListener('change', refreshState);
|
||||
});
|
||||
|
||||
hostSelect.addEventListener('change', async ()=>{
|
||||
await runAction('Changing flash host', 'Loading removable media candidates for the selected flash host.', async ()=>{
|
||||
await refreshState();
|
||||
if(!state.device_error){
|
||||
banner('success', 'Flash host ready', 'Loaded removable media candidates for ' + (hostSelect.value || state.default_flash_host) + '.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
render();
|
||||
setInterval(refreshState, 5000);
|
||||
clearBanner();
|
||||
setInterval(async ()=>{
|
||||
try {
|
||||
await refreshState({silent:true});
|
||||
} catch (_error) {
|
||||
// Keep the live dashboard calm during background polling.
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
@ -50,18 +50,35 @@ func TestUIAuthAcceptsForwardedSlashGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAuthAcceptsForwardedEmailForAllowedUser(t *testing.T) {
|
||||
func TestUIAuthRejectsUserWithoutAllowedGroup(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
app.settings.AllowedUsers = []string{"brad.stein@gmail.com"}
|
||||
handler := app.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
||||
req.Header.Set("X-Forwarded-Email", "Brad.Stein@gmail.com")
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateJSONUsesLowerCaseNodeFields(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
handler := app.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
||||
req.Header.Set("X-Auth-Request-User", "brad")
|
||||
req.Header.Set("X-Auth-Request-Groups", "admin")
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, `"name":"titan-15"`) {
|
||||
t.Fatalf("expected lowercase node name field in json, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalSnapshotAndWatch(t *testing.T) {
|
||||
|
||||
@ -19,7 +19,6 @@ type Settings struct {
|
||||
DefaultFlashHost string
|
||||
FlashHosts []string
|
||||
LocalHost string
|
||||
AllowedUsers []string
|
||||
AllowedGroups []string
|
||||
MaxDeviceBytes int64
|
||||
}
|
||||
@ -41,7 +40,6 @@ func FromEnv() Settings {
|
||||
DefaultFlashHost: defaultFlashHost,
|
||||
FlashHosts: flashHosts,
|
||||
LocalHost: localHost,
|
||||
AllowedUsers: splitList(getenvDefault("METIS_ALLOWED_USERS", "")),
|
||||
AllowedGroups: splitList(getenvDefault("METIS_ALLOWED_GROUPS", "admin,maintainer")),
|
||||
MaxDeviceBytes: getenvInt64("METIS_MAX_DEVICE_BYTES", 300000000000),
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user